TECH -- Per-client deployment/config approach (ID-95.3)
TECH — Per-client deployment/config approach (ID-95.3)
Section titled “TECH — Per-client deployment/config approach (ID-95.3)”Task: ID-95 — Per-client Supabase/infra topology + platform-vs-client ownership
boundary.
Subtask: {95.3} TECH.
Author date: 11/06/2026.
Status: SPEC — implementation plan. Fresh-Planner artefact (Q-PLANNER-2): this TECH was
authored by a Planner instance that did NOT write {95.2} PRODUCT; PRODUCT.md was read in
full as the contract. This document maps every Proposed change back to a PI-N invariant,
resolves the two open decisions PRODUCT deferred to TECH (OQ-5 tenant_config scope, OQ-6
durable brand-asset master), and stays inside the ratified topology (3-tier ladder,
tenant_config pivot, do-NOT-split-migrations). UK English throughout (DD/MM/YYYY,
“colour”, “organisation”, “behaviour”). Cross-references are by path; this document does NOT
re-dump RESEARCH/PRODUCT content — it cites it.
Scope discipline. PRIMARY items (T-A
tenant_configschema/RLS, T-B prebuild fetch, T-C re-seed manifest, T-D OQ-5, T-E OQ-6) carry full schema/script/interface detail. SECONDARY items PRODUCT deferred to{95.3}(taxonomy point-of-use PI-15, propagation mechanics PI-18/PI-19, the no-deploy-target-literal sweep PI-13/PI-14) are covered at TECH altitude or handed to a named owner with an explicit seam — none is silently dropped. Where this TECH proposes something PRODUCT does not mandate, it is marked (TECH-level proposal).
Context
Section titled “Context”What is being built
Section titled “What is being built”PRODUCT pins the behaviour of standing a client up from per-project env + branding
config-as-data in the client’s OWN Supabase project; this TECH pins the mechanism. Four
new mechanisms ship: (1) a generic public.tenant_config table + a private branding
Storage bucket in each client project (PI-10/PI-7/PI-9); (2) a committed prebuild fetch
script that hydrates lib/branding/clients/<id>.json + public/clients/<id>/* from that
project at build time, before the existing generate:branding codegen (PI-11/PI-2/AC-E3);
(3) a per-instance re-seed manifest covering signup_policy + tenant_config + bucket
objects (PI-20); (4) resolutions for the two open decisions. The existing branding
codegen/loader/contrast-gate are UNCHANGED — the fetch only writes the files the glob
already consumes.
Current state (code-intelligence orientation — cited, not paraphrased)
Section titled “Current state (code-intelligence orientation — cited, not paraphrased)”Code-intel orientation (gitnexus, index knowledge-hub; full tool catalogue in
.gitnexus/CLAUDE.md / .ast-dataflow/CLAUDE.md, not reproduced here):
gitnexus_query({query: 'client branding prebuild tenant config fetch'})-> top execution flowproc_276_rootlayout“RootLayout -> BuildBrandCss” (priority 0.098, 3 steps):RootLayout(app/layout.tsx:47-95) ->buildBrandCss(lib/client-config.ts:654-667, step 3 of the process). Standalone definitions returned (none in a process, i.e. all build-time codegen leaves):renderClientBrandingMap(scripts/generate-client-branding-map.ts:45-86),main(scripts/generate-client-branding-map.ts:88-96),loadBranding(lib/client-config.ts:603-626),validateBrandingContrast(lib/client-config.ts:497-541). The fetch step is therefore a NEW build-time leaf that runs strictly before this graph executes — it touches no symbol the loader imports.gitnexus_context({name: 'loadBranding'})-> incoming calls = 1 (the module filelib/client-config.tsitself — theBRANDINGmodule-init at:630); outgoing calls =validateBrandingContrast,ClientLogger.warn;processes: [](leaf). Verdict LOW: the fetch can add<id>.jsonto the glob input without altering any caller ofloadBranding. Confirms the dossier “BRANDING computed once at module init” finding (id95-oq-dossier-2026-06-11.md:20).gitnexus_context({name: 'buildBrandCss'})-> incoming caller =buildBrandStylePropsonly; participates inproc_276_rootlayoutstep 3. Confirms branding is build-inlined into the<style>element (no runtime fetch), so a build-time hydrate has zero runtime cost (PI-11 latency-moot).
renderClientBrandingMap caller count = 1 (main in the same script) reconfirms the
codegen seam is invoked only by the prebuild entry point.
Relevant code (read in full for this spec)
Section titled “Relevant code (read in full for this spec)”scripts/generate-client-branding-map.ts:46-87—renderClientBrandingMapglobslib/branding/clients/*.json->client-branding-map.generated.ts; REQUIRESdefault.json(:49-53); validates each id against^[a-z0-9][a-z0-9-]*$(:55-61); the overlay only ever ADDS files. UNCHANGED by this design — the fetch writes the files this glob then consumes.lib/client-config.ts:604-626—loadBranding()resolvesNEXT_PUBLIC_CLIENT_ID->CLIENT_BRANDING_MAP[id] ?? .default(:606);BRANDINGcomputed once at module init (:630);BrandingConfigSchema.parse+validateBrandingContrastrun at build (:614-625). UNCHANGED.lib/client-config.ts:393-417—BrandingConfigSchemaalready carriesclassificationDisambiguation(entityExamples[]+selfReferenceRules[]) — this is client CONFIG, not branding (relevant to OQ-5 / T-D).lib/env-server.ts:42-51—SUPABASE_URLis OPTIONAL (.optional().or(z.literal('')));SUPABASE_SERVICE_ROLE_KEYis REQUIRED (.min(1, 'SUPABASE_SERVICE_ROLE_KEY is required...')). The prebuild fetch uses the URL + service-role key already in each project’s env — no new credential surface (PI-11; Open decision 1 DISSOLVED).lib/env-client.ts:59-63—NEXT_PUBLIC_CLIENT_IDis REQUIRED (S196 guard); the fetch’s fail-closed branch keys offNEXT_PUBLIC_CLIENT_ID != 'default'.package.json:13—prebuild = generate:skills && generate:content-type-values && generate:branding;package.json:60—build:vercelchains the same trio beforenext build;package.json:15—generate:branding = bun run scripts/generate-client-branding-map.ts. The new fetch step inserts BEFOREgenerate:brandingin BOTH chains.supabase/migrations/20260609160000_config_table_signup_domain_policy.sql— the GENERIC config-as-data template: single-row table,ENABLE ROW LEVEL SECURITY,REVOKE ALL ON TABLE ... FROM anon, authenticated, public, a role-scoped GRANT + a PERMISSIVE SELECT policy, hook function withSET search_path = public, extensionsand an explicitREVOKE EXECUTE ... FROM public, anon, authenticated, service_role. NO client literal anywhere. This TECH MIRRORS this file fortenant_config; it does NOT model on the older client-named hook migrations (the2026042*_capture_<client>_domain_hook.sqlgeneration), which are ID-68 purge targets.lib/branding/clients/—default.json,<client>.json;public/clients/<client>/*holds the 5-asset set (favicon.png, favicon.svg, logo-dark.png, logo-dark.svg, logo.webp).lib/supabase/server.ts/lib/supabase/safe.ts— service-role client construction +sb()/tryQuery()safe-access wrappers (the fetch script reuses the service-role construction; it runs in Node at build time, not a feature path).
gitnexus orientation: existing symbols match — this is an additive build-time surface
over a known seam, not a greenfield feature.
Proposed changes
Section titled “Proposed changes”T-A. public.tenant_config schema + RLS (PI-10, PI-7, PI-9)
Section titled “T-A. public.tenant_config schema + RLS (PI-10, PI-7, PI-9)”(a) The table. One new migration, generic schema, NO client literal — mirroring the
signup_policy template (20260609160000_config_table_signup_domain_policy.sql). A single
canonical migration; do-NOT-split (RESEARCH §4). Proposed DDL (the document column shape is
the OQ-5 resolution, T-D):
-- supabase/migrations/<ts>_tenant_config_table.sql-- Per-instance branding + client config as DATA. NO client literal here.-- The config document is set out-of-band per environment (re-seed manifest, T-C),-- NEVER committed in a migration -- same config-as-data property as signup_policy.
CREATE TABLE IF NOT EXISTS public.tenant_config ( id boolean PRIMARY KEY DEFAULT true, config jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT tenant_config_singleton CHECK (id = true));
COMMENT ON TABLE public.tenant_config IS 'Single-row per-instance client config document (branding + per-client config such as classificationDisambiguation). config jsonb is set out-of-band per environment via the re-seed manifest, NEVER in committed migrations, so no client value enters tracked source. Read by the build-time fetch (scripts/fetch-client-branding.ts) via the service-role key.';Rationale for single-row-singleton (mirrors signup_policy): each Supabase project holds
exactly ONE client’s data (PI-5 physical isolation), so the project’s tenant_config has
exactly one row — a client_id key column would be a multi-tenant artefact that PI-6 forbids
and that PI-7 (no client literal) would not let us seed by name anyway. The singleton CHECK
is the same guard signup_policy uses. (TECH-level proposal: singleton-row over a
client_id-keyed table; justification above. If a future tier co-tenants config — it must
not, per PI-6 — this would change, but Tier-1/Tier-3 are both project-per-client.)
updated_at is maintained by the writer (re-seed manifest does
SET updated_at = now() on upsert); no trigger is required for a single-row table, keeping
the migration trigger-free and replay-safe (TECH-level proposal).
(b) RLS deny-all, service-role only (PI-10). Mirror the template’s REVOKE pattern. The
build-time fetch authenticates with the project’s SUPABASE_SERVICE_ROLE_KEY
(env-server.ts:49-51), which bypasses RLS, so — unlike signup_policy (read by
supabase_auth_admin via a permissive policy) — tenant_config needs NO permissive SELECT
policy and NO additional role GRANT. Deny-all to every client-reachable role is the whole
requirement:
ALTER TABLE public.tenant_config ENABLE ROW LEVEL SECURITY;REVOKE ALL ON TABLE public.tenant_config FROM anon, authenticated, public;-- service_role bypasses RLS by Supabase default; it is deliberately NOT listed in-- this REVOKE and needs no GRANT -- "service-role access only" is achieved by-- denying every OTHER role, not by granting service_role. No SELECT policy or-- extra GRANT is required.-- NOTE: no anon/authenticated GRANT, no permissive policy -> the table is-- unreachable from the public app bundle's anon key (PI-10 deny-all).This is stricter than signup_policy by design: signup_policy must be readable by the
auth-hook role at signup time; tenant_config is read ONLY by the build-time service-role
fetch, never by a request-path role, so it stays fully closed (PI-10 “service-role access
only”). (TECH-level proposal: no permissive policy at all — justified by the
service-role-only read path.)
(c) No PL/pgSQL function needed. tenant_config is a passive data table read by an
external script; it ships no function. (If a future helper function is added, it MUST carry
SET search_path = public, extensions and an explicit REVOKE EXECUTE ... FROM anon per the
CLAUDE.md function gotchas — noted for the Executor, not built here.)
(d) The branding Storage bucket (PI-10). A PRIVATE bucket named branding holds the
asset set (the 5 files: favicon.png, favicon.svg, logo-dark.png, logo-dark.svg, logo.webp,
under a per-instance prefix, e.g. branding/<id>/logo.webp). Bucket provisioning is a
DATA/config step, not schema — Storage buckets are not DDL and are NOT created by a
migration. Two provisioning options:
- Recommended: provision the bucket in the re-seed manifest (T-C) via the Storage API
with the service-role key (
createBucket('branding', { public: false }), idempotent on re-run), so bucket existence + objects + thetenant_configrow are one atomic per-instance step. (TECH-level proposal — the manifest owns bucket creation.) - Alternative (rejected): a Supabase
storage.bucketsINSERT in the migration. Rejected because (i) it couples Storage state to migration history (the thing config-as-data avoids) and (ii) bucket contents (the assets) cannot live in a migration anyway, so splitting bucket-create from object-upload across two mechanisms is worse than keeping both in the manifest.
Migration plan (PI-7/PI-9 compliant): exactly ONE new migration
(<ts>_tenant_config_table.sql), shipped in the canonical public supabase/migrations/**
set, greps clean of client tokens (passes the PI-9 denylist lint), fanned out to each client
project by the same supabase link + db push mechanism that delivers every other
migration (RESEARCH §9.1). Do-NOT-split (RESEARCH §4): it stays in the public repo,
genericised.
Maps to: PI-10 (private branding/config home =
tenant_config+ branding bucket), PI-7 (no client literal in any migration), PI-9 (denylist-clean migration).
T-B. Build-time prebuild branding fetch (PI-11, PI-2, AC-E3)
Section titled “T-B. Build-time prebuild branding fetch (PI-11, PI-2, AC-E3)”New committed script (TECH-level proposed path): scripts/fetch-client-branding.ts,
wired as generate:client-branding and inserted BEFORE generate:branding in BOTH chains:
// package.json (proposed)"generate:client-branding": "bun run scripts/fetch-client-branding.ts","prebuild": "... && generate:content-type-values && generate:client-branding && generate:branding","build:vercel":"... && generate:content-type-values && generate:client-branding && generate:branding && build:mcp-apps && next build",predev deliberately does NOT gain the fetch (local dev keeps the tracked default.json;
adding it would force a service-role key into every dev shell). (TECH-level proposal —
fetch in prebuild + build:vercel only, not predev.)
Behaviour (the exact fail-closed conditionals). The script reads
process.env.NEXT_PUBLIC_CLIENT_ID, process.env.SUPABASE_URL (server-side; note it is
OPTIONAL in env-server.ts, so the script treats absence as “no creds”), and
process.env.SUPABASE_SERVICE_ROLE_KEY:
id := NEXT_PUBLIC_CLIENT_ID ?? 'default'url := SUPABASE_URL (may be absent)key := SUPABASE_SERVICE_ROLE_KEY (may be absent at build for the control case)
CASE 1 -- control build (id === 'default' OR url/key absent): no-op. Do NOT fetch, do NOT write any <id>.json. Exit 0. -> the tracked default.json (and any tracked client file) is left untouched; generate:branding globs what is present; loadBranding resolves 'default'. (ID-68 DEPLOY-OVERLAY-DESIGN §6 acceptance 1-3: control build stays green.)
CASE 2 -- client build (id !== 'default' AND url AND key present): fetch tenant_config (single row) + download branding/<id>/* via service-role. IF no tenant_config row (or config has no branding document): FAIL THE BUILD -- process.exit(1) with a loud message naming the missing row + project ref. (S196 analogue: never silently ship a default-branded client deploy.) -> PI-11 fail-closed, client direction. ELSE: write lib/branding/clients/<id>.json (the branding document from config) write public/clients/<id>/<asset> (each downloaded bucket object) exit 0.Where the written files land relative to the glob. generate:branding
(renderClientBrandingMap, scripts/generate-client-branding-map.ts:46) globs
lib/branding/clients/*.json from process.cwd()/lib/branding/clients. The fetch writes
lib/branding/clients/<id>.json into exactly that directory, and the assets into
public/clients/<id>/* (the paths BrandingConfigSchema’s brandAssetExists refine checks,
lib/client-config.ts:390). Because the fetch runs FIRST, the glob picks up <id>.json
on the next step with no source patch; renderClientBrandingMap’s id-slug guard
(^[a-z0-9][a-z0-9-]*$, :55-61) is already satisfied by NEXT_PUBLIC_CLIENT_ID’s own
constraint, so a fetched id needs no extra validation. The existing codegen glob, loader
(loadBranding), and build-time schema/contrast validation
(BrandingConfigSchema.parse + validateBrandingContrast) then run UNCHANGED.
Credential surface. The script uses the URL + service-role key ALREADY in each project’s
env for runtime (env-server.ts:49-51) — no new credential, no private-repo read token.
Open decision 1 stays DISSOLVED. The service-role key is visible to the build step, but it
already sits in that project’s env (PI-11 nuance). The fetch script reads
process.env.SUPABASE_SERVICE_ROLE_KEY (and process.env.SUPABASE_URL) DIRECTLY and MUST
NOT import or invoke the lib/env-server.ts Zod parse: that schema declares
SUPABASE_SERVICE_ROLE_KEY REQUIRED (min(1), env-server.ts:49-51) and would hard-fail
any build where the key is legitimately absent — precisely the platform/control default
build (CASE 1) — breaking the fail-closed no-op. Reading process.env directly keeps CASE 1
coherent.
Implementation note. Reuse the service-role client construction in lib/supabase/server.ts
(or construct @supabase/supabase-js directly with url+key, since the script runs in Node at
build time outside the request context). The script is a build-time codegen leaf, not a
feature path, so sb()/tryQuery() (request-path safe-access) is not mandatory — but the
script MUST still fail loudly on any Storage/PostgREST error in CASE 2 (no silent catch),
matching the fail-closed contract. (TECH-level proposal — direct service-role client at
build time; loud-fail error handling.)
Maps to: PI-11 (generic build-time data fetch, mechanism preserved, fail-closed both ways), PI-2 (per-project build-time config set; no other per-client knob), AC-E3 (the gated verification sequence — see Testing).
T-C. Per-instance re-seed manifest (PI-20)
Section titled “T-C. Per-instance re-seed manifest (PI-20)”The problem. signup_policy, tenant_config, and the branding bucket objects are all
per-instance DATA (PI-7/PI-10). Any DB recut producing a fresh data state (ID-45 cutover, a
recut-to-new-project) wipes all three. PI-20 requires them re-applied together as one manifest.
Mechanism (TECH-level proposal): a generated, committed-template seed script keyed by
client, executed out-of-band. Concretely a script
scripts/reseed-tenant-instance.ts (generic, NO client literal — it reads its values from
an UNCOMMITTED, gitignored per-instance input, exactly like the signup_policy
out-of-band INSERT). The manifest is three idempotent operations against the target project
(selected by the --project-ref + service-role key, with the
cat supabase/.temp/project-ref relink-verify gotcha applied — CLAUDE.md):
reseed-tenant-instance (idempotent; service-role; against ONE project ref) 1. signup_policy row: INSERT INTO public.signup_policy (allowed_domain) VALUES (:domain) ON CONFLICT (id) DO UPDATE SET allowed_domain = EXCLUDED.allowed_domain; 2. tenant_config row: INSERT INTO public.tenant_config (config) VALUES (:config_jsonb) ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config, updated_at = now(); 3. branding bucket: ensure bucket 'branding' exists (createBucket, idempotent); upload each asset to branding/<id>/<file> (upsert: true).The per-instance INPUT (the :domain, :config_jsonb, and the asset files) comes from the
durable master (T-E / OQ-6), NOT from any repo. The manifest is the REPLAY half of OQ-6: the
durable master is the system-of-record, this script is the restore path into a fresh project.
Capture vs replay. Capture is a read-side dump from the durable master (T-E); replay is
this script. The script is committed (generic mechanism); the per-instance values are never
committed (config-as-data). This is the signup_policy out-of-band pattern generalised from
one row to three artefacts, exactly as PI-20 specifies.
Maps to: PI-20 (fail-closed signup gate + per-instance re-seed manifest covering signup_policy + tenant_config + branding bucket). Ties to OQ-6 (durable master, T-E).
T-D. Resolution — OQ-5 (tenant_config scope)
Section titled “T-D. Resolution — OQ-5 (tenant_config scope)”RECOMMENDED: a general per-client config document, NOT branding-only.
Evidence. BrandingConfigSchema (lib/client-config.ts:393-417) ALREADY embeds
classificationDisambiguation (entityExamples[] + selfReferenceRules[] with
clientOrganisationShort, canonicalName, reason) inside the same client JSON document
that holds the branding colours/logos. That field is client CONFIG (entity-resolution rules
for the client’s organisation), not branding — a branding-ONLY tenant_config would orphan
it, forcing a second home for config that travels with the same client and the same recut.
Resolution shape. tenant_config.config jsonb holds the WHOLE current client document —
i.e. the existing BrandingConfigSchema-shaped JSON (branding + classificationDisambiguation
- any future per-client config), unchanged. The build-time fetch (T-B) writes
configverbatim tolib/branding/clients/<id>.json, where the existingBrandingConfigSchema.parsevalidates it at build — so the validation contract is reused, not duplicated. Naming the columnconfig(notbranding) makes the general-document intent explicit and lets future per-client config land WITHOUT a schema change (it is just more keys in the jsonb). The bucket stays branding-asset-only (binary assets do not belong in a jsonb document).
signup_policy stays a SEPARATE table regardless (PI-20) — its read path is the
supabase_auth_admin auth-hook role with a permissive SELECT policy, a different grant
profile from tenant_config’s service-role-only-deny-all. Folding them would force
tenant_config open to supabase_auth_admin, weakening PI-10’s full closure. Keep two
tables; the re-seed manifest (T-C) re-applies both.
Owner note (Liam ratifies): this is the lower-churn, higher-cohesion option — it reuses
BrandingConfigSchema as-is, keeps classificationDisambiguation with its client, and adds
no second config home. The only cost is a slightly less precise table name (config not
branding), accepted for the future-config headroom.
T-E. Resolution — OQ-6 (durable brand-asset master)
Section titled “T-E. Resolution — OQ-6 (durable brand-asset master)”RECOMMENDED: the platform control-plane DB is the system-of-record, pushed to client projects via the PI-18 one-way channel; named owner = the platform (Liam) via the provisioning process.
Options considered.
- Operator archive (a file/S3 archive of
<id>.json+ assets held by the operator). Durable, simple, but off-platform and easy to drift from the live client document; no single mechanism ties it to the PI-18 propagation already being built. - Platform control-plane DB (the canonical source PI-18 already curates). The master
client document + asset references live as a control-plane record; the existing
one-directional push (PI-18: platform -> client upsert by stable key) carries it INTO the
client project’s
tenant_config+ bucket on (re)provision. Recommended — it reuses the mechanism PI-18 mandates, gives ONE system-of-record, and the restore path is just the re-seed manifest (T-C) reading from the control plane. - Re-obtain from client at each recut. Rejected as a standing answer — it makes every recut a client-coordination event and risks a recut blocking on the client.
Resolution. The platform control-plane DB holds the durable master client document and
the master asset set (binary assets in a control-plane Storage bucket, referenced from the
record). On a recut-to-new-project, the re-seed manifest (T-C) sources its :config_jsonb
and asset files FROM the control plane (the same one-way push the canonical-content
propagation uses, RESEARCH §9.4) — no re-obtain-from-client, no off-platform archive to
drift. Named owner: the platform provisioning process (Liam), as the control-plane curator
(PI-18). Restore path: control-plane master -> re-seed manifest (T-C) -> fresh client
tenant_config + bucket -> build-time fetch (T-B) -> branded deploy.
Scope note. The control-plane DB and the PI-18 worker are themselves named-but-unbuilt (see SECONDARY, propagation mechanics below); OQ-6’s resolution NAMES the system-of-record and the restore path, and rides the same PI-18 build. Until PI-18 lands, the interim master is an operator archive (option 1) as a stopgap — but the durable answer is the control plane. (TECH-level proposal; Liam ratifies the control-plane-as-master direction and the interim stopgap.)
SECONDARY scope (PRODUCT deferred these to {95.3})
Section titled “SECONDARY scope (PRODUCT deferred these to {95.3})”Taxonomy point-of-use generation (PI-15) — DEFERRED to ID-71 with a named seam
Section titled “Taxonomy point-of-use generation (PI-15) — DEFERRED to ID-71 with a named seam”Disposition: specified at altitude, mechanics handed to ID-71 (PRODUCT permits ID-71 co-ownership). The end-state PI-15 pins: baseline vocabulary reaches client DBs via the PI-18 fan-out (platform -> client upsert); per-tenant DERIVED artefacts (taxonomy snapshot, plugin vocabulary, classification prompt) are generated POINT-OF-USE against the target client DB rather than committed to the public repo.
Named seam for ID-71. The three derived artefacts are produced today by sync:taxonomy
(package.json:34: generate-classification-prompt-taxonomy.ts +
generate-taxonomy-snapshot.ts + sync-plugin-taxonomy.ts + build:plugin), which writes
into the public repo and is currently BROKEN (PI-15: fails without KH_PRIVATE_DOCS_DIR
since {68.23}). The seam: these three generators must take a TARGET DB parameter (a
--db-url / project-ref) and write to a point-of-use destination (the client project, or a
per-deploy artefact) instead of committing to main. Owner: ID-71 ai-tooling
(the same Task that owns the MCP-plugin URL carve-out, PI-13/PI-14). This TECH does NOT build
the generator refactor; it records the seam (target-DB-parameterised generators, no new
public-repo commit) so ID-71 can pick it up. Follow-up: an ID-71 subtask, or a {95.3} TECH
revision if ID-71 declines co-ownership.
One-directional propagation mechanics (PI-18/PI-19) — SCOPE-NOTED as a follow-up TECH revision
Section titled “One-directional propagation mechanics (PI-18/PI-19) — SCOPE-NOTED as a follow-up TECH revision”Disposition: named, NOT fully specified here — it is a follow-up {95.3} TECH revision
(or its own Task), and it is the build OQ-6 (T-E) rides on. RESEARCH §9.4 is the reference:
a catalog (client -> DB URL) + a small fan-out worker + a per-client version table
(tenant_migrations-style), with per-era transport — DB-direct service-role upsert at Tier 1,
GitOps-delivered versioned payload at Tier 3 (PI-19: no postgres_fdw, no logical replication
for regulated clients). Payload format: upsert-by-stable-key with tombstone deletes (RESEARCH
§9.2 Pattern 2).
Decision named: this TECH does NOT specify the worker/version-table/payload schema —
that is a substantial mechanism worth its own spec slice. It is scope-noted as a follow-up
{95.3} TECH revision (or a dedicated propagation Task) because (i) PI-18/PI-19 are
PRODUCT-altitude behaviour PRODUCT itself defers, (ii) it is the dependency T-E/OQ-6 leans on,
and (iii) building it inline would balloon this TECH past the branding/config remit the brief
scopes. Owner: Orchestrator to schedule — either a {95.3} revision or a new Task; flagged
as an OQ below.
No-deploy-target-literal sweep (PI-13/PI-14) — thin pointer
Section titled “No-deploy-target-literal sweep (PI-13/PI-14) — thin pointer”Disposition: brief mechanics + ID-71 carve-out, ordering-before-rename restated. The sweep
replaces the 17+ hardcoded knowledge-hub-seven-kappa.vercel.app references (runtime code:
components/intelligence/rss-feed-panel.tsx:118, lib/intelligence/rss-generator.ts:37,
content-extractor.ts:9, url-validation.ts:17; CI: taxonomy-sync.yml:98,119,
ci.yml:1468-1472 deploy-gate check name) with NEXT_PUBLIC_APP_URL-derived values,
following the established precedent components/settings/mcp-url.ts getMcpUrl() (PI-13).
Mechanics: an ast-dataflow string-literal-uses sweep for the literal across the TS corpus
plus a grep sweep for the SQL/Python/workflow files (ast-dataflow is TS-only —
.ast-dataflow/CLAUDE.md), each site rewritten to env-derivation; regenerate the plugin bundle
only when ID-71 lands its derivation. Ordering (PI-14): the sweep + the Vercel deploy-gate
check-name verification on both sides MUST complete BEFORE the knowledge-hub ->
knowledge-hub-<client> rename. Carve-out: the MCP plugin tree
(.claude/plugins/knowledge-hub/1.0.0/** + lib/mcp/plugin-bundle.ts) is EXEMPT — owned by
ID-71 (no live client connectors, accepted breakage = none). This TECH does not over-build the
sweep; it points at the sweep + the ordering gate. Owner: the implementation Subtask wave
(sweep) + ID-71 (plugin residue).
Testing and validation
Section titled “Testing and validation”Each PRODUCT invariant this TECH touches maps to a concrete check. Tests follow KH
test-philosophy (reference/test-philosophy.md): verify real behaviour, not implementation.
Run with bun run test (not bun test).
- PI-10 / PI-7 / PI-9 (T-A). (i) Apply the
tenant_configmigration to a fresh staging project; assert the table exists, RLS is enabled, andanon/authenticatedhave no SELECT (a query with the anon key returns permission-denied / empty, not rows). (ii) Grepsupabase/migrations/**for client tokens -> the new migration is clean (PI-9 denylist lint passes). (iii) Regeneratedatabase.types.tsfrom the live DB and confirmtenant_configrow/jsonb shape is exposed viaTables<'tenant_config'>(no hand-edit). - PI-11 / PI-2 / AC-E3 (T-B) — the gated checklist (PRODUCT PI-11 Verify sequence,
ID-68 DEPLOY-OVERLAY-DESIGN §6 acceptance 1-3):
- Rehearsal/preview deploy with
lib/branding/clients/<client>.json+public/clients/<client>/*REMOVED from the tree,NEXT_PUBLIC_CLIENT_ID=<client>, project env present -> page renders the client branding (hydrated fromtenant_config). git rmthe two tracked paths on the public HEAD (git ls-filesreturns zero rows for both).- Production redeploy -> re-verify branded.
- Control build:
NEXT_PUBLIC_CLIENT_ID=default(or absent creds) -> fetch no-ops,loadBrandingresolvesdefault, build stays GREEN. - Fail-closed negative: a
<client>build whosetenant_confighas no branding document -> the fetch exits non-zero and FAILS the build (S196 analogue). A unit test onscripts/fetch-client-branding.tsasserts CASE 2 / no-row ->process.exit(1)and CASE 1 / default -> no-op-zero (mock the service-role client; no live DB needed).
- Rehearsal/preview deploy with
- PI-20 (T-C). Re-seed manifest integration check: against a freshly-recut DB with NO
rows, assert (a) all signups rejected (fail-closed) BEFORE the manifest runs; (b) after the
manifest: an allowed-domain signup succeeds, an off-domain one is 403’d
(
client-app-deploy.md:120-127),tenant_confighas its row, and thebrandingbucket has its objects. Re-run the manifest -> idempotent (no duplicate-row error, upsert clean). - OQ-5 (T-D). Round-trip test: a
tenant_config.configdocument containing branding +classificationDisambiguationis fetched, written to<id>.json, andloadBranding+BrandingConfigSchema.parseaccept it unchanged (the existing branding schema validates the general document — no orphaned config). - OQ-6 (T-E). Restore-path test (once PI-18 control plane exists): control-plane master ->
re-seed manifest -> fresh project
tenant_config+ bucket -> build fetch -> branded deploy, with no re-obtain-from-client step. - PI-13/PI-14 (sweep).
ast-dataflow string-literal-uses+grepforknowledge-hub-seven-kappareturn zero outside the ID-71 plugin tree; the cutover record shows the sweep + deploy-gate check completed BEFORE the rename.
Branding colours/states use Warm Meridian semantic tokens, never raw colours, never colour
alone for meaning (design/warm-meridian-implementation-spec.md).
Risks and mitigations
Section titled “Risks and mitigations”- Service-role key in the build step. The fetch needs the service-role key at build time.
Mitigation: it already sits in each project’s env for runtime (
env-server.ts:49-51); no NEW credential surface, no private-repo token (PI-11; Open decision 1 DISSOLVED). The key is never written into any committed artefact — only used to fetch. .temp/project-refdrift on fan-out (CLAUDE.md gotcha). Applying the migration or running the re-seed manifest against the WRONG project ref is the classic drift hazard. Mitigation: every fan-out/manifest step doescat supabase/.temp/project-ref+ relink beforedb push/ write; the worker (PI-18) carries the catalog of refs so the target is explicit, not link-state-derived.- Silent fetch failure shipping a default-branded client (S196 class). Mitigation: CASE 2
fail-closed — any missing row or Storage error in a
!= defaultbuild is a loudprocess.exit(1); no silentcatch. This is the single most important behavioural test (PI-11 negative). - Storage bucket not provisioned before object upload. Mitigation: the manifest creates the bucket idempotently (T-A(d)) before uploading objects, so a fresh project does not 404 on the first upload.
- OQ-6 master not yet built. The control-plane DB / PI-18 worker is named-but-unbuilt. Mitigation: interim operator-archive stopgap (T-E option 1) until PI-18 lands; the durable answer rides the propagation build. Flagged as a dependency, not a blocker on the branding cutover.
- Schema parity prod <-> staging. The new migration must land on every client project +
the platform project (PI-3) + staging via the canonical fan-out;
schema-parity.ymlwill flag any project that missed it.
Follow-ups
Section titled “Follow-ups”- Propagation mechanics (PI-18/PI-19) — scheduled as a follow-up
{95.3}TECH revision or its own Task (Orchestrator’s call). It is the build OQ-6/T-E rides on. See OQ below. - Taxonomy point-of-use generators (PI-15) — handed to ID-71 with the target-DB-parameterised seam named above.
- No-deploy-target-literal sweep (PI-13/PI-14) — an implementation Subtask in the ID-95 wave (sweep) + ID-71 (plugin residue); ordering-before-rename is the hard gate.
client-app-deploy.mdrunbook update — step 3c still prescribes the dead GUC command (RESEARCH OQ-95-7) and the new-client checklist must add thetenant_configrow + bucket upload steps (PI-12). Runbook lag, not a code change; flag to the Orchestrator for the docs-site lane.
Open questions for the Orchestrator
Section titled “Open questions for the Orchestrator”- OQ (propagation scheduling): PI-18/PI-19 propagation mechanics (worker + catalog +
version table + payload) are named here but NOT specified — this TECH scopes them as a
follow-up
{95.3}revision or a dedicated Task. T-E/OQ-6’s durable-master resolution depends on that build existing. The Orchestrator must decide WHICH (revision vs new Task) and sequence it relative to the first recut. This does not block the branding cutover (interim operator-archive stopgap covers the gap), but it must be scheduled before the first recut-to-new-project for the durable master to exist.