---
output_file: "Lattice - Publish API v2.md"
feed: "WHITEPAPER"
template_version: "1.2"
source: "Lattice"
codex_section: "S16-S20"
conv_id: "lattice-build-history"
share_url: "none"
date: "2026-06-22"
content_date: "2026-06-22"
enriched_date: "2026-07-02"
date_modified: "2026-07-02"
message_count: 1
type: "lattice-build-spec"
status: "deposited"
path_hint: "IntelligentNetware/Lattice/"
category:
- "Lattice Infrastructure"
- "Publish API"
tags:
- "lattice"
- "synna"
- "publish-api"
- "ventrian"
- "dnn"
- "hub-vps"
- "tailscale"
monomyth_stage: "08 - The Ordeal"
gameboard_position: "Server-side create-only commit spec; replaces manual SSMS"
summary: |
  SynNA Publish API v2 spec for server-side create-only Ventrian commit on each fractome hub VPS.
  Laptop assembles and SFTPs; hub validates bundle rebuilds references from Template8 with authoritative ArticleID executes localhost SQL returns ArticleID—no SQL credentials on laptop.
  v2 changelog fixes categories table images_published state bundle shape and idempotency rules..
keypoints:
  - Trust boundary: laptop never holds hub SQL credentials; commit runs on hub over localhost
  - Server rebuilds references_html from Template8 using authoritative article_id not predicted na_id
  - body_html is extract_dnn_page_body output; single Page row SortOrder 0
  - images_published state set by both SFTP and record-publish before commit
sentiment: archival
emotions:
  - "purposeful"
sycophancy_level: "None"
truth_score: 95
entropy_score: "Low"
codex: true
pre_silicon: false
ma5_inheritance: "IntelligentNetware"
voice_print: false
lattice_release: "3.0"
original_handoff: "SPEC_SynNA_Publish_API_v2.md"
---
# 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 + returned `article_id` (§5)
- `dnn_database`, `module_id`, `userid`, image folder, `ImageUrl`, `URL`

**Bundle rules:**
- `body_html` MUST be post-`extract_dnn_page_body()` — no banner `<picture>`, no module header row, no duplicate hook.
- `categories`: 1–5 integer IDs (same as `na_record.categories`).
- `image_manifest`: exactly the four crop rows (thumb + 3 banner sizes) unless packet documents inline prose images as additional Image rows (match `collect_publish_files()` behavior).
- `author_key`: key into `operators{}` (e.g. `daniel`, `arnie`). Laptop derives via same logic as `_publish_operator_for_packet()` in `app.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`):
1. Load Template8 from server path (mirror of `assets/Templates/_Template8_References_Block_v1.html`).
2. Replace `{{ArticleID}}`, `{{ARTICLEID}}`, `{{NA_ID}}`, `{{na_id}}` with returned `article_id`.
3. 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)

1. **Auth.** Valid Bearer key. Else `401`.
2. **Bundle validation.** Required fields, types, 1–5 categories, four image manifest entries. Mirror `_na_record_validation()` blockers (title, hook, categories, no `127.0.0.1` in body if server re-checks). Else `422` with `blockers[]`.
3. **Author allowlist.** `author_key` ∈ server `operators`. Else `422`.
4. **Image manifest precondition.** Every file exists under server `ventrian.image_folder`. Missing → `422` with `missing_files[]`. Optionally verify `bytes` matches `os.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 `slug` in index → `200`, `already_published: true`, same `article_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 `500` with 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 `200` replay — safe.
- If response lost, SQL committed, index not written: retry may duplicate — mitigate by checking index first on every request; optional v2.1: `title`+`CreatedDate` dedup 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:**
1. `INSERT DnnForge_NewsArticles_Article` → `SCOPE_IDENTITY()` → `article_id`.
2. Build `references_html` from Template8 + `article_id` (§5).
3. `INSERT DnnForge_NewsArticles_Page` — single row, `SortOrder = 0`, `PageText = body_html`.
4. `INSERT DnnForge_NewsArticles_Image` — 4 rows (thumb sort 0, banner 1, tablet 2, mobile 3).
5. `INSERT DnnForge_NewsArticles_ArticleCategories` — Cat1 always; Cat2–5 if not null (same IF pattern as `syn_sql_build.py`).
6. `INSERT DnnForge_NewsArticles_CustomValue` — badge (field 1), references (field 2).
7. `COMMIT`.
8. Write `syn_publish_index.json` entry (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 local `409`.
- On `200`: append ledger, reconcile `dnn_article_registry.yaml` + `syn_config.publish.next_na_id` to `article_id + 1`, set `state = published`, move packet dir to `paths.published_root/{slug}/`.

**`build-sql` (optional, operator debug):**
- Still available; generates `{slug}_insert.sql` from `syn_ventrian` for SSMS diff.
- Does **not** gate commit.
- May run from `proof_approved` or `images_published` if `publish.sftp_at` set.

**`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_ventrian` self-test passes; NA 240 insert matches `{slug}_insert.sql` field semantics
- [ ] Fresh slug → Article + Page + 4 Images + Categories + 2 CustomValues; live render OK
- [ ] Re-POST same slug → `200` replay, zero new rows
- [ ] Missing image on disk → `422`, zero rows
- [ ] Bad key → `401`; unknown `author_key` → `422`
- [ ] Spoofed `module_id` in body (if sent) → ignored
- [ ] References CustomValue contains correct `syn-us?na={returned_id}`
- [ ] Forced mid-txn failure → full rollback
- [ ] `1433` dark from laptop over Tailscale
- [ ] Ledger + registry reconciled to returned `article_id`
- [ ] `images_published` gate enforced on commit
- [ ] `record-publish` path reaches commit
- [ ] Packet archived under `published_root` after commit

---

## 17. Build order (Grok_Build)

1. **`scripts/syn_ventrian.py`** — factor from `syn_sql_build.py`; parameterized ops; `render_sql_script()` for SSMS debug; self-test against makers-exodus / next packet.
2. **Refactor `syn_sql_build.py`** — thin wrapper calling `syn_ventrian`.
3. **`images_published` state** — `syn_publish.py` `_finalize_publish`; update `app.py` / `index.html` gate buttons.
4. **`scripts/syn_publish_client.py`** — bundle builder + POST + ledger + registry reconcile.
5. **`scripts/syn_publish_api.py`** + `syn_publish_server.yaml.example` — VPS service (dev: mock SQL or Daniel's RDP test).
6. **`syn_pipeline.py --commit`** + `app.py` `POST /api/pipeline/{slug}/commit`.
7. **Archive on publish** in pipeline.
8. **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.