Skip to content

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_config schema/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).


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 flow proc_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 file lib/client-config.ts itself — the BRANDING module-init at :630); outgoing calls = validateBrandingContrast, ClientLogger.warn; processes: [] (leaf). Verdict LOW: the fetch can add <id>.json to the glob input without altering any caller of loadBranding. Confirms the dossier “BRANDING computed once at module init” finding (id95-oq-dossier-2026-06-11.md:20).
  • gitnexus_context({name: 'buildBrandCss'}) -> incoming caller = buildBrandStyleProps only; participates in proc_276_rootlayout step 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-87renderClientBrandingMap globs lib/branding/clients/*.json -> client-branding-map.generated.ts; REQUIRES default.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-626loadBranding() resolves NEXT_PUBLIC_CLIENT_ID -> CLIENT_BRANDING_MAP[id] ?? .default (:606); BRANDING computed once at module init (:630); BrandingConfigSchema.parse + validateBrandingContrast run at build (:614-625). UNCHANGED.
  • lib/client-config.ts:393-417BrandingConfigSchema already carries classificationDisambiguation (entityExamples[] + selfReferenceRules[]) — this is client CONFIG, not branding (relevant to OQ-5 / T-D).
  • lib/env-server.ts:42-51SUPABASE_URL is OPTIONAL (.optional().or(z.literal(''))); SUPABASE_SERVICE_ROLE_KEY is 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-63NEXT_PUBLIC_CLIENT_ID is REQUIRED (S196 guard); the fetch’s fail-closed branch keys off NEXT_PUBLIC_CLIENT_ID != 'default'.
  • package.json:13prebuild = generate:skills && generate:content-type-values && generate:branding; package.json:60build:vercel chains the same trio before next build; package.json:15generate:branding = bun run scripts/generate-client-branding-map.ts. The new fetch step inserts BEFORE generate:branding in 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 with SET search_path = public, extensions and an explicit REVOKE EXECUTE ... FROM public, anon, authenticated, service_role. NO client literal anywhere. This TECH MIRRORS this file for tenant_config; it does NOT model on the older client-named hook migrations (the 2026042*_capture_<client>_domain_hook.sql generation), 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.


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 + the tenant_config row are one atomic per-instance step. (TECH-level proposal — the manifest owns bucket creation.)
  • Alternative (rejected): a Supabase storage.buckets INSERT 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 config verbatim to lib/branding/clients/<id>.json, where the existing BrandingConfigSchema.parse validates it at build — so the validation contract is reused, not duplicated. Naming the column config (not branding) 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.

  1. 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.
  2. 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.
  3. 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).


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_config migration to a fresh staging project; assert the table exists, RLS is enabled, and anon/authenticated have no SELECT (a query with the anon key returns permission-denied / empty, not rows). (ii) Grep supabase/migrations/** for client tokens -> the new migration is clean (PI-9 denylist lint passes). (iii) Regenerate database.types.ts from the live DB and confirm tenant_config row/jsonb shape is exposed via Tables<'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):
    1. 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 from tenant_config).
    2. git rm the two tracked paths on the public HEAD (git ls-files returns zero rows for both).
    3. Production redeploy -> re-verify branded.
    4. Control build: NEXT_PUBLIC_CLIENT_ID=default (or absent creds) -> fetch no-ops, loadBranding resolves default, build stays GREEN.
    5. Fail-closed negative: a <client> build whose tenant_config has no branding document -> the fetch exits non-zero and FAILS the build (S196 analogue). A unit test on scripts/fetch-client-branding.ts asserts CASE 2 / no-row -> process.exit(1) and CASE 1 / default -> no-op-zero (mock the service-role client; no live DB needed).
  • 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_config has its row, and the branding bucket has its objects. Re-run the manifest -> idempotent (no duplicate-row error, upsert clean).
  • OQ-5 (T-D). Round-trip test: a tenant_config.config document containing branding + classificationDisambiguation is fetched, written to <id>.json, and loadBranding + BrandingConfigSchema.parse accept 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 + grep for knowledge-hub-seven-kappa return 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).


  • 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-ref drift 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 does cat supabase/.temp/project-ref + relink before db 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 != default build is a loud process.exit(1); no silent catch. 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.yml will flag any project that missed it.

  • 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.md runbook update — step 3c still prescribes the dead GUC command (RESEARCH OQ-95-7) and the new-client checklist must add the tenant_config row + bucket upload steps (PI-12). Runbook lag, not a code change; flag to the Orchestrator for the docs-site lane.

  • 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.