Skip to content

PLAN -- Per-client topology + PI-18 propagation worker (ID-95.4)

PLAN — Per-client deployment/config + canonical-content propagation (ID-95.4)

Section titled “PLAN — Per-client deployment/config + canonical-content propagation (ID-95.4)”

Task: ID-95 — Per-client Supabase/infra topology + platform-vs-client ownership boundary. Subtask: {95.4} PLAN — decomposition of the ratified {95.2} PRODUCT + {95.3} TECH into sibling-only implementation Subtasks {95.5+}. Author date: 12/06/2026. Status: SPEC — decomposition. Fresh-Planner artefact (Q-PLANNER-2): this PLAN was authored by a Planner instance that did NOT write PRODUCT or TECH; both were read in full as the contract. UK English throughout (DD/MM/YYYY, “colour”, “organisation”, “behaviour”). Cross-references by path; this document does NOT re-dump PRODUCT/TECH/RESEARCH — it cites them.

This PLAN does two things the brief mandates: (1) it carries the branding/config remit the TECH fully specified (T-A/T-B/T-C/T-D/T-E) straight into implementable Subtasks, and (2) it DESIGNS, at TECH altitude, the PI-18 one-way platform->client propagation worker that PRODUCT pinned as behaviour and TECH explicitly scope-noted as “a substantial mechanism worth its own spec slice” — then decomposes it. The propagation worker is the centrepiece.


Code-intelligence orientation (cited, not paraphrased)

Section titled “Code-intelligence orientation (cited, not paraphrased)”

Index knowledge-hub. Run this session for the propagation domain.

  • gitnexus_query({query: 'canonical content propagation taxonomy fan-out client database upsert'}) -> top process proc_249_put “PUT -> Error” (priority 0.051), a single unrelated content-upload handler (handleQABatchDismiss, components/create-content/upload-tab-content.tsx:515-520). No propagation / fan-out / migration-orchestration execution flow exists in the corpus. Interpretation: the PI-18 worker is a GREENFIELD orchestration surface — there is no existing fan-out loop or catalog to extend; it is built fresh over the known config-as-data seam. The returned definitions are all cocoindex extraction tests + taxonomy-validation tests (scripts/cocoindex_pipeline/extraction.py, scripts/tests/test_cocoindex_extraction.py) — i.e. the taxonomy CONSUMERS, not a propagation producer.
  • gitnexus_context({name: 'loadBranding', file_path: 'lib/client-config.ts'}) -> incoming = {} (no callers indexed beyond the module-init at :630); outgoing.calls = validateBrandingContrast, ClientLogger.warn; processes: [] (leaf). Verdict LOW. Confirms PRODUCT/TECH: the T-B build-time fetch can add <id>.json to the glob input with ZERO blast radius — it touches no symbol the loader imports.

Grounding reads this session (read-only, cited for the Checker):

  • supabase/migrations/20260609160000_config_table_signup_domain_policy.sql (read in full) — the config-as-data template T-A mirrors: single-row singleton CHECK, ENABLE ROW LEVEL SECURITY, REVOKE ALL ... FROM anon, authenticated, public, out-of-band INSERT documented in the header, NO client literal.
  • package.json:13 prebuild = generate:skills && generate:content-type-values && generate:branding; :58 build:vercel = ... && generate:branding && build:mcp-apps && next build; :34 sync:taxonomy = generate-classification-prompt-taxonomy.ts && generate-taxonomy-snapshot.ts && sync-plugin-taxonomy.ts && build:plugin. The T-B fetch inserts BEFORE generate:branding in both build chains, never in predev.
  • lib/supabase/server.ts:44-61 — the service-role construction reads URL from clientEnv.NEXT_PUBLIC_SUPABASE_URL and key from serverEnv.SUPABASE_SERVICE_ROLE_KEY, BOTH Zod-validated at boot. This confirms T-B’s hard rule: the fetch script must NOT reuse this construction (it would drag in the env-server Zod parse that hard-requires the service-role key and breaks the CASE 1 control build); it reads process.env directly.
  • components/settings/mcp-url.ts:11-17getMcpUrl() derives the endpoint from clientEnv.NEXT_PUBLIC_APP_URL with a window.location.origin fallback; the PI-13 derivation precedent.
  • Table inventory via mcp__supabase__list_tables(turayklvaunphgbgscat) — the client-class staging DB. The platform-curated canonical tables that are the PI-18 payload candidates: taxonomy_domains (15), taxonomy_subtopics (57), layer_vocabulary (4), application_types (6), form_types (8), form_template_requirements (96 — the catalogue requirements), reference_items (2 — global reference layer). These are platform-curated baseline, distinct from client-provenance rows (content_items, guides, entity_mentions, etc.) which the worker NEVER touches.
  • seven-kappa grep (worktree): 7 sites — the 6 the TECH enumerates PLUS one the TECH missed, scripts/seed-procurement-test-data.ts:488. Folded into the PI-13 sweep Subtask.

The load-bearing grounding fact — the platform DB is EMPTY

Section titled “The load-bearing grounding fact — the platform DB is EMPTY”

Verified this session (do not re-derive): the platform DB zjqbrdctesqvouboziae (knowledge-hub-platform, org ptacqvoidhqznzdnzpbg, eu-west-2) public schema is EMPTY — zero tables. “Initial config complete” meant Vercel project + domain + DSN, NOT DB schema. The canonical SOURCE the PI-18 worker reads FROM does not yet exist and must be stood up. This is the unresolved core of OQ-95-3 (“what IS the platform canonical source”). It is surfaced here as an explicit decision and a [GATED] Subtask, because:

  1. Every DDL/schema change against zjqbrdctesqvouboziae is a CARE point. It is brand-new infra Liam just provisioned. Any platform-DB DDL requires parent GO via the OQ channel (oq_emit) BEFORE execution; DDL via CLI only, never MCP. Subtasks that prescribe platform-DB DDL are prefixed [GATED: platform-DB GO].
  2. The branding/config remit (T-A/T-B/T-C) does NOT touch the platform DB — those are client-side migrations + pure-TS scripts, buildable with NO platform GO. They are the unblocked critical path to the first-client cutover.
  3. The PI-18 worker’s client-side half (version table, fan-out target writes) is client-side and buildable; only the SOURCE bootstrap + the catalog-if-platform-resident are gated.

{95.5} tenant_config table (client-side migration, T-A)
|
+---------------+----------------+
| |
{95.6} fetch-client-branding {95.7} reseed-tenant-instance
(T-B prebuild, PI-11) (T-C manifest, PI-20; creates bucket)
| |
+---------------+----------------+
|
{95.8} AC-E3 gated verification (PI-11/PI-2)
-- PI-13/PI-14 lane (independent of the branding lane; ordering gate INTERNAL) --
{95.9} no-deploy-target-literal sweep (PI-13) -> {95.10} rename ordering-gate record (PI-14)
-- PI-18 propagation worker (centrepiece; designed at TECH altitude in this PLAN) --
{95.11} payload contract + version table migration (client-side, PI-18)
|
+--> {95.12} [GATED] platform canonical-source + catalog bootstrap (platform-DB DDL)
|
+--> {95.13} fan-out worker script (Tier-1 DB-direct, PI-18/PI-19)
| (consumes both {95.11} version table and {95.12} source)
v
{95.14} OQ-6 durable-master restore-path wiring (T-E; rides {95.12}+{95.7})
-- docs lane (docs-only) --
{95.15} client-app-deploy.md runbook update (docs-site; PI-12/PI-20/OQ-95-7)

Architecture decisions (PLAN-level, within the ratified topology)

Section titled “Architecture decisions (PLAN-level, within the ratified topology)”

D-1: T-A/T-B/T-C are the unblocked critical path; PI-18 is the gated centrepiece

Section titled “D-1: T-A/T-B/T-C are the unblocked critical path; PI-18 is the gated centrepiece”

The branding/config remit the TECH fully specified is buildable today with no platform GO. It ships first as the first-client cutover enabler. The PI-18 worker is designed here but its SOURCE bootstrap is GATED — so the worker decomposes into a buildable client-side half ({95.11}, {95.13} against staging/client targets) and a gated platform-DB half ({95.12}).

D-2: PI-18 worker mechanism (TECH-altitude design — the part TECH deferred)

Section titled “D-2: PI-18 worker mechanism (TECH-altitude design — the part TECH deferred)”

RESEARCH §9.1/§9.2/§9.4 is the reference; this PLAN pins the mechanism so it can be decomposed. Tier-1 only (Tier-3 GitOps transport is a named, NON-decomposed follow-up). Four components:

  1. Catalog (client -> DB connection). Maps each client to its Supabase project-ref + service-role connection. Decision: config-as-DATA, NOT a platform-DB table for the Tier-1 build. The catalog is a gitignored, out-of-band operator input (scripts/.propagation-catalog.json or env-driven --targets list of {ref, db_url}), exactly mirroring the signup_policy / re-seed out-of-band pattern. Rationale: (i) a platform-DB catalog table would be platform-DB DDL = an extra GATED dependency on the worker’s critical path, and (ii) a connection catalog holding service-role DSNs must never be committed anyway (config-as-data, PI-7). If/when the platform control-plane DB is bootstrapped ({95.12}), the catalog MAY graduate to a control-plane table — noted as a {95.12} extension, not a Tier-1 requirement. This keeps the fan-out worker ({95.13}) buildable without platform GO.
  2. Per-client version tablepublic.content_propagation_version (a tenant_migrations-style ledger: payload_key text, version bigint, applied_at timestamptz, payload_checksum text). A CLIENT-SIDE public migration (genericised, no client literal), fanned to every client DB + staging exactly like tenant_config. NOT platform-DB DDL -> NOT gated. ({95.11}.)
  3. Fan-out worker (scripts/propagate-canonical-content.ts) — iterates the catalog; for each target reads the canonical payload from the SOURCE, applies upsert-by-stable-key + tombstone deletes (RESEARCH §9.2 Pattern 2), records the version in content_propagation_version. One-way only: the worker NEVER reads client rows back into the source (PI-18). No postgres_fdw, no logical replication — a plain service-role upsert over a per-target connection creates no standing cross-DB dependency (PI-19). Each target connection applies the cat supabase/.temp/project-ref relink-verify discipline; the catalog carries the explicit ref so the target is never link-state-derived. ({95.13}.)
  4. Payload definition — the platform-curated canonical table set: taxonomy_domains, taxonomy_subtopics, layer_vocabulary, application_types, form_types, form_template_requirements (catalogue requirements), reference_items (global reference layer). Decision: these seven are the v1 payload, each upserted by a stable natural key (slug / canonical_name / normalised URL), with tombstone deletes keyed off a source-side “active” set. Client-provenance tables (content_items, guides, entity_mentions, q_a_pairs, form_responses, …) are EXPLICITLY EXCLUDED — the worker pushes baseline, never client data. The exact per-table stable-key + tombstone mapping is pinned in {95.11}‘s payload-contract artefact so {95.13} implements against a written contract, not a guess.

D-3: the platform canonical-source bootstrap is the gated core of OQ-95-3

Section titled “D-3: the platform canonical-source bootstrap is the gated core of OQ-95-3”

Because the platform DB is empty, the SOURCE the worker reads from must be stood up: the seven payload tables (+ the control-plane catalog if it graduates per D-2) created on zjqbrdctesqvouboziae, then seeded with the platform-curated baseline. This is platform-DB DDL -> [GATED: platform-DB GO]. It is also the OQ-6 (T-E) durable-master home: once the source exists, the re-seed manifest ({95.7}) and the restore path ({95.14}) read from it. Until it lands, T-E’s interim operator-archive stopgap stands (TECH T-E option 1).

D-4: OQ-6 (T-E) durable master rides the PI-18 source

Section titled “D-4: OQ-6 (T-E) durable master rides the PI-18 source”

Per TECH T-E: the platform control-plane DB is the system-of-record for the durable branding master, pushed to client tenant_config + bucket via the same one-way channel. The restore path (control-plane master -> re-seed manifest -> fresh client tenant_config + bucket -> build fetch -> branded deploy) is wired in {95.14}, which depends on both the source bootstrap ({95.12}) and the re-seed manifest ({95.7}). Until {95.12} lands, {95.14} documents the interim operator-archive stopgap and is NOT a blocker on the branding cutover.

D-5: Tier-3 BYOC GitOps transport is named, NOT decomposed

Section titled “D-5: Tier-3 BYOC GitOps transport is named, NOT decomposed”

Per owner ratification (feedback + PI-6a): Tier-3 BYOC is OUT-OF-SCOPE-FOR-BUILD. The PI-18 design above is Tier-1 only (DB-direct service-role push). The Tier-3 GitOps transport (versioned payload via a private per-client repo reconciled by Coolify, RESEARCH §9.4 / §10) is named as a deferred follow-up and is NOT decomposed into buildable Subtasks. The Tier-1 design satisfies the PI-19 forward-compatibility requirement: one-way upsert with no fdw / no logical replication means the same payload contract serves the Tier-3 era unchanged — only the transport swaps.

The existing deploy/coolify/** host was built FOR the first client (Phew) and re-points to the client’s own domain at handover (the aisolutionhub.co.uk domain was interim DNS scaffolding). The platform needs its OWN Coolify deployment (Tier-3 framing, build-timing out of scope). No Subtask asserts “Phew has no Coolify”. No Coolify build Subtask is created (out of scope per PI-6a).


Subtask catalogue (narrative; the TM-shape JSON is the RETURN payload)

Section titled “Subtask catalogue (narrative; the TM-shape JSON is the RETURN payload)”
IDTitleMaps toGated?Parallel group
{95.5}tenant_config table + RLS deny-all + branding bucket noteT-A / PI-10,7,9noA (schema)
{95.6}scripts/fetch-client-branding.ts prebuild fetchT-B / PI-11,2noB (TS, disjoint)
{95.7}scripts/reseed-tenant-instance.ts manifest + bucket createT-C / PI-20noB (TS, disjoint)
{95.8}AC-E3 gated verification (rehearsal/rm/redeploy/control/fail-closed)PI-11,2 / AC-E3noC (verify)
{95.9}no-deploy-target-literal sweep (7 sites)PI-13noD (disjoint files)
{95.10}rename ordering-gate record (sweep+deploy-gate before rename)PI-14noD (record)
{95.11}PI-18 payload contract + content_propagation_version migrationPI-18 / RESEARCH §9noA (schema)
{95.12}[GATED] platform canonical-source + catalog bootstrapOQ-95-3 / T-EYESE (platform DDL)
{95.13}PI-18 fan-out worker (Tier-1 DB-direct, one-way)PI-18,19 / RESEARCH §9.4noF (worker)
{95.14}OQ-6 durable-master restore-path wiringT-E / OQ-6partial*F (worker)
{95.15}client-app-deploy.md runbook update (docs-only)PI-12,20 / OQ-95-7noG (docs)
{95.16}Archive demo-bootstrap-spec.md + leave pointer (docs-only)PI-17noG (docs)

* {95.14} depends on the gated {95.12}; its interim-stopgap half is buildable, its control-plane restore half waits on {95.12}‘s GO.

Checker resolution (S344, checker-95-4 PASS_WITH_NOTES, 6 in-scope notes, 0 blockers). Applied before the Orchestrator returned add-subtasks intents: (N1) PI-17 added as {95.16} (docs-only: archive demo-bootstrap-spec.md, leave a superseding pointer) — PRODUCT PI-17 said “archive NOW” and had no tracing. (N2) PI-9 is NOT a net-new ID-95 CI workflow: the lint mechanism already exists as ID-68 identity-guard.yml (full-tree git grep -F of KH_CLIENT_NAME_DENYLIST, PR-blocking on main, covers supabase/migrations/** because it scans the whole tracked tree). A client-name token in a new migration already fails CI. PI-9’s residual (client-DOMAIN-literal coverage + the R8 bare-stem rule) is routed to ID-68 below; {95.5}’s migration greps clean. Parent to confirm “PI-9 satisfied-by-ID-68” (see OQ). (N3) {95.13} dep corrected [95.11,95.12] -> [95.11]; {95.12} is a LIVE-RUN gate (worker code + unit tests build now against a mock source), not a hard dispatch dep. (N4) {95.14} dep corrected [95.7,95.12] -> [95.7]; the interim operator-archive half is buildable now, the control-plane restore half is a {95.12} LIVE-RUN gate noted in details. (N5) phantom {95.20} prose reference (was line ~292) corrected to {95.7}. (N6) per-subtask Executor code-intel blocks ARE present in the RETURN details (the Checker reviewed the summary list, not the JSON) — confirmed, no change.

  • Parallel-safe (disjoint file sets): {95.6} (scripts/fetch-client-branding.ts + package.json chain), {95.7} (scripts/reseed-tenant-instance.ts), {95.9} (lib/intelligence/** + components/intelligence/** + scripts/seed-procurement-test-data.ts
    • .github/workflows/taxonomy-sync.yml) can run concurrently after {95.5}/{95.11} land.
  • Serialised (shared schema ordering): {95.5} and {95.11} are both supabase/migrations/** additions — author them as two separate migration files (distinct timestamps) so they do not collide; land {95.5} first (branding critical path), {95.11} second. Both must fan out to staging + client + platform-equivalent.
  • {95.13} consumes {95.11} (version table) AND {95.12} (source) — serialise after both; but {95.13} can be built and unit-tested against a MOCK source + staging target before {95.12}‘s platform GO (the gated dependency is the LIVE source, not the worker code).
  • {95.8} AC-E3 serialises after {95.6} + {95.7} (it exercises the fetch against a seeded tenant_config + bucket).
  • {95.10} records the ordering gate; it serialises after {95.9} and BEFORE any Vercel rename (the rename itself is an operator step, not a code Subtask).

Exactly one Subtask is [GATED: platform-DB GO]: {95.12} (platform canonical-source + catalog bootstrap on zjqbrdctesqvouboziae). Its details carry the standing block: “BLOCKED pending parent GO (oq_emit) — platform-DB DDL on zjqbrdctesqvouboziae; DDL via CLI only, never MCP.” {95.14}‘s control-plane restore half inherits the same gate transitively (it reads from the source {95.12} stands up) but its interim operator-archive stopgap half is buildable now. All other Subtasks are client-side migrations (tenant_config, version table -> staging/client) or pure-TS / docs -> NOT gated.


Sibling-only dependency check (forcing function — §3.3 / A6)

Section titled “Sibling-only dependency check (forcing function — §3.3 / A6)”

Every dependency in the RETURN JSON is another {95.x} sibling. No cross-Task Subtask dependency is encoded. Where the spec genuinely depends on another Task, it is surfaced here for the Orchestrator (NOT encoded as a dep):

  • ID-71 ai-tooling owns: the MCP-plugin seven-kappa residue (plugin-bundle.ts + .claude/plugins/knowledge-hub/1.0.0/**, PI-13/PI-14 carve-out) AND the taxonomy point-of-use generator refactor (PI-15 seam: target-DB-parameterised generators). {95.9} EXCLUDES the plugin tree by construction (it is ID-71’s), so no cross-Task dep is needed — but the Orchestrator must confirm ID-71 carries the plugin-residue + taxonomy-generator Subtasks. No sibling-violation; flagged for routing.
  • ID-68 owns: the git-history purge backstop (the three Phew-bearing migration files), the ledger/continuation-prompt perimeter (PI-23), AND the main_session_output.md de-ID/untrack finding (owner routed it to ID-68’s perimeter — it is a docs-site transcript-tooling fix, not ID-95). {95.5} and {95.11} produce NEW clean migrations; they do not depend on the purge. No sibling-violation; flagged for routing.
  • ID-45 re-ingest cutover produces the first-client handover DB; {95.7} re-seeds AFTER a recut but does not gate on the cutover mechanics. No dep encoded.

No Task-split or Task-merge escalation is required: the ID-95 boundary holds. Every implementation slice above lives inside ID-95’s sibling space.


Cross-Task routing appendix (for the Orchestrator — per owner directive)

Section titled “Cross-Task routing appendix (for the Orchestrator — per owner directive)”

The owner directed that the S-current findings are NOT backlog — they move to the correct task-id as NEW Subtasks. Per the sibling-only constraint these do NOT become ID-95 Subtasks; the Orchestrator routes them to their owning Tasks:

FindingOwning TaskNote
main_session_output.md de-ID + untrack (update the global-Claude tools/ generator to emit to a transient .session-transcript/ dir in docs-site, untracked)ID-68Owner: easy fix, sub-agent-resolvable; transcript tooling, not load-bearing. NOT an ID-95 Subtask.
R8 bare-stem rule + guard-list drift (ID-95.md, s328-s332 prompts, cmux-brief-s267 not in PENDING_DEID; migration 20260609093000 hook_restrict_signup_to_phew_domain literal)ID-68Inside PI-23 / ID-68 purge perimeter.
harness eval imports broken (harness/scripts/eval-classification.ts:383 imports non-existent ../lib/ai/classify)ID-68 (docs-site harness) or its owning eval Taskdocs-site harness lane; not ID-95.
taxonomy-sync automation-shell retirement + point-of-use generator refactor (PI-15 seam)ID-71TECH SECONDARY names the seam: target-DB-parameterised generators, no new public-repo commit.
docs-site root vitest unwired in its CIdocs-site repodocs-site CI config; not ID-95.
MCP-plugin seven-kappa residue (plugin-bundle.ts + plugin tree)ID-71PI-13/PI-14 carve-out; regenerate via bun run build:plugin when ID-71 lands its derivation.
PI-9 CI denylist-lint residual: client-DOMAIN-literal coverage in supabase/migrations/** (client-NAME tokens already PR-blocked by ID-68 identity-guard.yml full-tree grep)ID-68PI-9’s lint mechanism already exists (identity-guard, full tracked-tree scan incl. migrations). Residual = add client-domain literals to ops/identity-denylist.json + resolve the R8 bare-stem rule (already routed above). No net-new ID-95 CI workflow; {95.5} greps clean. Parent confirms “PI-9 satisfied-by-ID-68”.

The PI-18 propagation-mechanics scheduling OQ (TECH “Open questions for the Orchestrator”): this PLAN decomposes the Tier-1 PI-18 worker INTO ID-95 Subtasks ({95.11}-{95.14}) rather than deferring to a new Task — because OQ-6 (the durable master) and the first recut both need it, and it fits ID-95’s sibling space. The Tier-3 GitOps transport remains a named, NON-decomposed follow-up (D-5). The Orchestrator should confirm this absorb-into-ID-95 decision (vs spinning a dedicated propagation Task) at wave planning.


RiskImpactMitigation
Platform-DB DDL run before parent GOHigh (mutates brand-new infra){95.12} [GATED]; standing block in details; DDL via CLI only, never MCP.
.temp/project-ref drift on fan-out / worker writes to WRONG projectHigh (silent wrong-DB write)Catalog carries explicit ref; every step cat supabase/.temp/project-ref + relink before write (CLAUDE.md gotcha).
Silent fetch failure ships default-branded client deploy (S196 class)High{95.6} CASE 2 fail-closed process.exit(1); {95.8} negative test is the single most important behavioural check.
Fetch script imports lib/env-server.ts Zod parse and breaks CASE 1Medium{95.6} reads process.env DIRECTLY; explicit must-not-import note (TECH T-B).
Worker reads client rows back into source (PI-18 violation)High (isolation breach){95.13} one-way-only; payload contract excludes client-provenance tables; no fdw / no logical replication (PI-19).
Rename before sweep breaks deploy-gate auto-domainHigh (prod gate){95.10} records sweep + deploy-gate verification BEFORE any rename.
Two migration files collide on timestampLow{95.5} and {95.11} authored as distinct-timestamp files; {95.5} lands first.

  • Absorb-vs-new-Task (PI-18): this PLAN absorbs the Tier-1 PI-18 worker into ID-95 Subtasks rather than spinning a dedicated propagation Task. Confirm at wave planning.
  • {95.12} GO timing: the platform canonical-source bootstrap is gated. The branding cutover (T-A/T-B/T-C) does NOT wait on it (interim operator-archive stopgap covers OQ-6). But {95.12} must be GO’d before the first recut-to-new-project for the durable master to exist. The Orchestrator sequences the oq_emit GO relative to the first recut.
  • Catalog graduation (D-2): the Tier-1 catalog is config-as-data (gitignored). Whether it graduates to a control-plane table is a {95.12} extension; confirm whether to build the table form in {95.12} or keep config-as-data for v1.
  • PI-9 satisfied-by-ID-68 (checker N2): confirm PI-9 needs no net-new ID-95 CI workflow — ID-68 identity-guard.yml already PR-blocks client-name tokens across the full tracked tree (incl. supabase/migrations/**); PI-9’s residual (client-domain-literal coverage + R8 bare-stem) is routed to ID-68. If the parent wants a migrations-scoped standalone lint instead, that is a new ID-95 Subtask to add.