SPEC
SPEC
— SynNA Publish API (Create-Only Commit)
For: Grok_Build (implementation executor) Authored by: Claude — Axial Refinement Sherpa, MA5 Council Amended by: Grok Build — v2 incorporates code review against live pipeline (2026-06-22) Ratified by: Daniel Comp — Carbon Gnosis Steward Status: v2 · ready to build · Tier 1 (fractome hub-owner laptops) Supersedes: handoffs/SPEC_SynNA_Publish_API_v1.md Anchored to: app.py (aiohttp, laptop), scripts/syn_pipeline.py, scripts/syn_config.yaml, Ventrian News Articles module 546
v2 changelog (from v1)
| Change | Why |
| 1 | §9: categories → DnnForge_NewsArticles_ArticleCategories (not CustomValue) | v1 step 4 was wrong; matches syn_sql_build.py |
|---|---|---|
| 2 | Add images_published state; SFTP + record-publish both set it | _finalize_publish today only sets publish.sftp_at, not state |
| 3 | Simplify publish bundle: no pages[]; body_html = extract_dnn_page_body() output | Single Page row, SortOrder = 0 only |
| 4 | Server rebuilds references_html from Template8 + authoritative article_id | Laptop uses predicted na_id; drift would break syndication evidence |
| 5 | image_manifest[].bytes required; server may verify via os.stat as fallback | Ventrian Image.Size column |
| 6 | Idempotency index/SQL non-atomicity documented + retry rules | JSON file ≠ SQL transaction |
| 7 | build-sql becomes optional SSMS/debug artifact via syn_ventrian | Commit path does not require {slug}_insert.sql |
| 8 | VPS deployment path + Windows service account for SQL auth | trusted_connection needs explicit identity |
| 9 | Pin custom_field_badge_id=1, custom_field_references_id=2 on server | Same as module_id: 546 |
| 10 | packet_sha256 canonical JSON rules | Stable hash across retries |
| 11 | Commit success archives packet to published_root | Spec mentioned; not in pipeline yet |
| 12 | PortalID: 0 documented as site context only — not an INSERT column | Ventrian Article uses ModuleID |
0. What this builds, in one sentence
The missing terminal step of the SynNA pipeline: a server-side, create-only commit endpoint that runs on each fractome's hub VPS, executes the Ventrian insert into that hub's DNN database over a localhost connection, and returns the authoritative ArticleID — replacing the manual SSMS COMMIT, with no SQL credentials ever leaving the laptop.
Everything before this step already exists (publish-images, record-publish, optional build-sql). This spec adds the COMMIT and the trust boundary around it. Ventrian schema semantics are factored into syn_ventrian.py, not re-written.
1. Topology and trust boundary
`` FRACTOME-OWNER LAPTOP HUB VPS (fractal-server) ─────────────────── ─────────────────────── app.py / syn_pipeline.py syn_publish_api.py - assemble, gate 1–3 ┌──────▶ - validates fractome key - build publish bundle from packet │ - verifies image manifest on disk - SFTP images ──────────(:22 Tailscale)┤ - rebuilds references from Template8 - POST publish bundle ───(:8443 Tailscale) - executes txn → 127.0.0.1 SQL - writes LEDGER locally ◀─────────────┘ - returns authoritative ArticleID SQL Server: 127.0.0.1 ONLY ``
The two-hop rule (load-bearing):
- Laptop → VPS: API over Tailscale (
:8443) and SFTP over Tailscale (:22). Nothing else. - VPS → SQL Server:
127.0.0.1. Port 1433 is never bound to Tailscale and never public.
One server = one fractome. Each hub VPS runs one syn_publish_api.py bound to one DNN database. Multi-fractome routing from a single laptop is Tier 2 (§13).
Fractome model: Hub managers publish into portal 0 module 546 on the syndication hub DB. Sub-let portal operators never receive publish credentials.
2. New components
| Component | Location | Purpose |
|---|---|---|
scripts/syn_ventrian.py | Lattice repo (laptop + copied to VPS) | Single Ventrian insert contract. Returns parameterized ops for Article, Page, Image, ArticleCategories, CustomValue. Used by syn_sql_build.py, server API, and optional operator tools. |
scripts/syn_publish_client.py | Lattice repo (laptop) | Build publish bundle from packet; POST to hub API; ledger + registry reconcile. |
scripts/syn_publish_api.py | hub VPS (W:/_LatticePublish/shared/) | aiohttp service. Tailscale bind, key auth, preconditions, transaction, idempotency. |
syn_publish_server.yaml | hub VPS | Server identity + secrets. Never on laptop. (§9) |
syn_publish_index.json | hub VPS | Idempotency: slug → ArticleID. Not audit ledger. (§7) |
scripts/dnn_publish_ledger.yaml | laptop | Append-only provenance ledger. (§8) |
POST /api/pipeline/{slug}/commit | laptop app.py | UI route → syn_pipeline.py --commit or direct client call. |
VPS deployment: Ship syn_ventrian.py + syn_publish_api.py + syn_publish_server.yaml + server copy of assets/Templates/_Template8_References_Block_v1.html to a fixed folder on the hub VPS. Version-pin to Lattice release tag or commit hash recorded in ledger server_build field.
3. Request contract — the publish bundle
POST https://<tailscale-host>:8443/api/syn/publish
Headers: `` Authorization: Bearer <fractome_publish_key> Content-Type: application/json ``
Body (JSON) — content only, never identity: ``json { "slug": "first-tracks-reversal", "title": "Intium PRIME 523 [Portal:Keywords] First Tracks Reversal", "summary": "Full hook paragraph…", "body_html": "…prose + Template7 bottom only — output of extract_dnn_page_body()…", "categories": [ 12, 27 ], "badge": "maker", "author_key": "arnie", "publish_at": "2026-06-22T00:00:00Z", "expire_at": null, "image_manifest": [ { "filename": "first-tracks-reversal_200.jpg", "role": "thumb", "w": 200, "h": 200, "sort": 0, "bytes": 8421 }, { "filename": "first-tracks-reversal_1920x420.jpg", "role": "banner", "w": 1920, "h": 420, "sort": 1, "bytes": 198432 }, { "filename": "first-tracks-reversal_768x420.jpg", "role": "tablet", "w": 768, "h": 420, "sort": 2, "bytes": 61204 }, { "filename": "first-tracks-reversal_480x420.jpg", "role": "mobile", "w": 480, "h": 420, "sort": 3, "bytes": 38901 } ] } ``
Not sent by client (server derives):
references_html— server renders from Template8 + returnedarticle_id(§5)dnn_database,module_id,userid, image folder,ImageUrl,URL
Bundle rules:
body_htmlMUST be post-extract_dnn_page_body()— no banner<picture>, no module header row, no duplicate hook.categories: 1–5 integer IDs (same asna_record.categories).image_manifest: exactly the four crop rows (thumb + 3 banner sizes) unless packet documents inline prose images as additional Image rows (matchcollect_publish_files()behavior).author_key: key intooperators{}(e.g.daniel,arnie). Laptop derives via same logic as_publish_operator_for_packet()inapp.py.slug: idempotency key; create-only natural key.
packet_sha256 (laptop, for ledger): SHA-256 of canonical JSON: json.dumps(bundle, sort_keys=True, separators=(',', ':'), ensure_ascii=False) before POST.
4. Field ownership — the security spine
| Field | Source | Notes |
|---|---|---|
slug, title, summary, body_html, categories, badge, publish_at, expire_at | Client (packet) | Parameter-bound at insert. |
author_key → UserID | Client names; server resolves | Unknown key → 422. Never accept raw UserID. |
image_manifest | Client | Precondition check on disk; builds Image rows. |
references_html | Server | Template8 + authoritative article_id. Client value ignored. |
ArticleID | Server / DB | SCOPE_IDENTITY(). Authoritative. |
dnn_database, ModuleID (546), image folder DB path | Server config | |
CustomFieldID 1 (badge), 2 (references) | Server constants | |
ImageUrl = NULL, URL = NULL, FileCount = 0, ImageCount = 4 | Server (Ventrian constants) | |
Title apostrophe | Normalized to U+2019 in syn_ventrian | Same as syn_sql_build.py |
StartDate | Server | publish_at if future, else NULL (existing CASE logic) |
CreatedDate / LastUpdate | Server | GETDATE() on insert |
5. References rebuild (server-side, load-bearing)
Laptop _merge_na_record() fills references using predicted next_article_id. Pre-commit validation requires syn-us evidence matching prediction. If identity drifts, stored references would be wrong.
Server rule: Before CustomValue insert for references (CustomFieldID = 2):
- Load Template8 from server path (mirror of
assets/Templates/_Template8_References_Block_v1.html). - Replace
{{ArticleID}},{{ARTICLEID}},{{NA_ID}},{{na_id}}with returnedarticle_id. - If facilitated attribution applies (server config flag mirroring
attribution.facilitated_author_key), append steward<li>from server config.
Laptop may still send predicted refs for Gate 3 proof display; server ignores them at commit.
6. Preconditions (fail closed, before any write)
- Auth. Valid Bearer key. Else
401. - Bundle validation. Required fields, types, 1–5 categories, four image manifest entries. Mirror
_na_record_validation()blockers (title, hook, categories, no127.0.0.1in body if server re-checks). Else422withblockers[]. - Author allowlist.
author_key∈ serveroperators. Else422. - Image manifest precondition. Every file exists under server
ventrian.image_folder. Missing →422withmissing_files[]. Optionally verifybytesmatchesos.stat(warn or 422 on mismatch — prefer 422).
Only after all four pass does the transaction open.
7. Create-only + idempotency
Create-only. No update/delete. Corrections via DNN site editor.
Idempotency index (syn_publish_index.json on VPS): ``json { "first-tracks-reversal": { "article_id": 240, "committed_at": "2026-06-22T18:30:04Z", "title_hash": "sha256:…" } } ``
- If
slugin index →200,already_published: true, samearticle_id. No insert. - After successful SQL
COMMIT, write index entry immediately.
Index/SQL non-atomicity (explicit):
- JSON index and SQL are not one transaction.
- If SQL commits but index write fails: return
500with body{ "error": "index_write_failed", "article_id": 240, "retry_safe": false }. Client must not blind-retry; reconcile manually or call ops endpoint. - If response lost but SQL committed and index written: retry returns
200replay — safe. - If response lost, SQL committed, index not written: retry may duplicate — mitigate by checking index first on every request; optional v2.1:
title+CreatedDatededup query before insert (out of scope v2 unless time permits).
Three artifacts:
- DNN DB — article
- VPS index — technical dedup
- Laptop
dnn_publish_ledger.yaml— audit provenance
8. Lattice audit ledger (laptop)
Path: scripts/dnn_publish_ledger.yaml (create on first commit).
```yaml
- committed_at: "2026-06-22T18:30:04Z"
fractome: "scotomaville" slug: "first-tracks-reversal" article_id: 240 syndication_url: "https://scotomaville.com/syn-us?na=240" author_key: "arnie" author_userid: 5 packet_sha256: "…" image_manifest: [ first-tracks-reversal_200.jpg, … ] server_host: "100.69.160.85" key_id: "scotomaville-2026" server_build: "lattice@abc123" result: "created" ```
9. Server config — `syn_publish_server.yaml` (VPS only)
```yaml fractome: "scotomaville" bind: host: "100.69.160.85" port: 8443 tls: enabled: true cert_file: "W:/_LatticePublish/scotomaville/certs/tailscale.pem" key_file: "W:/_LatticePublish/scotomaville/certs/tailscale-key.pem" auth: keys: - id: "scotomaville-2026" secret_hash: "<bcrypt hash>" database: driver: "ODBC Driver 18 for SQL Server" server: "127.0.0.1" name: "IN9_Scotomaville_Syn"
Option A: Windows service account with trusted connection
trusted_connection: true
Option B: localhost SQL login (never on laptop)
user: "LatticePublish"
password_env: "LATTICE_PUBLISH_SQL_PASSWORD"
encrypt: true ventrian: module_id: 546 portal_id: 0 image_folder: "W:/IN9_Scotomaville_Syn/Portals/0/images/articles/arnie" image_folder_db: "images/articles/arnie/" public_base_url: "https://syn.scotomaville.com/Portals/0/images/articles/arnie" syndication_url_pattern: "https://scotomaville.com/syn-us?na={na_id}" custom_field_badge_id: 1 custom_field_references_id: 2 references_template: "W:/_LatticePublish/scotomaville/templates/_Template8_References_Block_v1.html" attribution: facilitated_author_key: "arnie" steward_reference_html: '<li><em>Carbon steward: Daniel Comp · Lattice facilitation</em></li>' operators: daniel: { userid: 3 } angelina: { userid: 4 } arnie: { userid: 5, username: "syndicator" } index_file: "W:/_LatticePublish/scotomaville/syn_publish_index.json" deploy: lattice_scripts_version: "v2-build" ```
Service account: Run syn_publish_api.py as a dedicated Windows service identity trusted by SQL Server (local LatticePublish login with INSERT on Ventrian tables only, or Windows auth mapped to db_datawriter on IN9_*_Syn).
10. The transaction (`syn_publish_api.py` core)
- Driver:
pyodbc,autocommit=False. syn_ventrian.build_insert_ops(packet, identity)→ ordered list of(sql, params)plus metadata.- Parameter binding only for content strings. U+2019 normalization in Python before bind.
Order, one SQL transaction:
INSERT DnnForge_NewsArticles_Article→SCOPE_IDENTITY()→article_id.- Build
references_htmlfrom Template8 +article_id(§5). INSERT DnnForge_NewsArticles_Page— single row,SortOrder = 0,PageText = body_html.INSERT DnnForge_NewsArticles_Image— 4 rows (thumb sort 0, banner 1, tablet 2, mobile 3).INSERT DnnForge_NewsArticles_ArticleCategories— Cat1 always; Cat2–5 if not null (same IF pattern assyn_sql_build.py).INSERT DnnForge_NewsArticles_CustomValue— badge (field 1), references (field 2).COMMIT.- Write
syn_publish_index.jsonentry (after commit success).
On any exception before COMMIT: ROLLBACK; index untouched.
Response: ``json { "ok": true, "article_id": 240, "syndication_url": "https://scotomaville.com/syn-us?na=240", "already_published": false } ``
11. Error taxonomy
| HTTP | Condition |
|---|---|
200 | Created or idempotent replay |
400 | Malformed JSON / missing required fields |
401 | Bad/missing API key |
422 | Validation blockers, unknown author_key, missing images, bytes mismatch |
500 | DB failure (rolled back) or index write after commit (see §7) |
Client: non-2xx = not published (except replay 200). index_write_failed with article_id = manual reconcile, no retry.
12. Laptop state machine
`` proof_approved → publish-images (SFTP) or record-publish (manual W: + HTTPS verify) → images_published → commit (POST hub API) → published (+ archive to published_root) ``
| State | Set by |
|---|---|
images_published | _finalize_publish() in syn_publish.py after successful upload/verify |
published | commit success on laptop |
/api/pipeline/{slug}/commit:
- Requires
state == images_published. Else local409. - On
200: append ledger, reconcilednn_article_registry.yaml+syn_config.publish.next_na_idtoarticle_id + 1, setstate = published, move packet dir topaths.published_root/{slug}/.
build-sql (optional, operator debug):
- Still available; generates
{slug}_insert.sqlfromsyn_ventrianfor SSMS diff. - Does not gate commit.
- May run from
proof_approvedorimages_publishedifpublish.sftp_atset.
record-publish: Manual image path when SFTP times out — must also advance to images_published (same gate as SFTP).
13. Laptop config additions (`syn_config.yaml` publish section)
```yaml publish:
… existing sftp_* fields …
api_host: "100.69.160.85" api_port: 8443 api_key_file: "D:/04_WORK/Code/_APIs/scotomaville_publish_key.txt" api_key_id: "scotomaville-2026" ledger_file: "dnn_publish_ledger.yaml" ```
Key file contains raw secret only (one line). Never commit to git.
14. Explicitly out of scope (v2)
- DNN JWT
- Multi-fractome routing (
publish.fractomes{}) — Tier 2 hook only - Portal-operator publishing
- DNN File API / daily folder sync on publish path
- Republish / upsert / delete
- Publish ledger inside DNN database
- SQL over Tailscale from laptop
15. Tailscale ACL
``` hub-owner-laptop → hub-server:22 hub-owner-laptop → hub-server:8443
NOT
:1433 from anywhere off-box ```
16. Acceptance checklist
- [ ]
syn_ventrianself-test passes; NA 240 insert matches{slug}_insert.sqlfield semantics - [ ] Fresh slug → Article + Page + 4 Images + Categories + 2 CustomValues; live render OK
- [ ] Re-POST same slug →
200replay, zero new rows - [ ] Missing image on disk →
422, zero rows - [ ] Bad key →
401; unknownauthor_key→422 - [ ] Spoofed
module_idin body (if sent) → ignored - [ ] References CustomValue contains correct
syn-us?na={returned_id} - [ ] Forced mid-txn failure → full rollback
- [ ]
1433dark from laptop over Tailscale - [ ] Ledger + registry reconciled to returned
article_id - [ ]
images_publishedgate enforced on commit - [ ]
record-publishpath reaches commit - [ ] Packet archived under
published_rootafter commit
17. Build order (Grok_Build)
scripts/syn_ventrian.py— factor fromsyn_sql_build.py; parameterized ops;render_sql_script()for SSMS debug; self-test against makers-exodus / next packet.- Refactor
syn_sql_build.py— thin wrapper callingsyn_ventrian. images_publishedstate —syn_publish.py_finalize_publish; updateapp.py/index.htmlgate buttons.scripts/syn_publish_client.py— bundle builder + POST + ledger + registry reconcile.scripts/syn_publish_api.py+syn_publish_server.yaml.example— VPS service (dev: mock SQL or Daniel's RDP test).syn_pipeline.py --commit+app.pyPOST /api/pipeline/{slug}/commit.- Archive on publish in pipeline.
- Daniel: deploy API to fractal-server; generate API key; first live commit NA 240.
NA 238 proved the Ventrian write model. v2 wraps it in a fractome-scoped trust boundary safe for hub-owner laptops.