Skip to content

feat(tenancy): capture tenant_id at ingest — Phase 1, capture only - #39

Open
DarrenZal wants to merge 2 commits into
stablefrom
darren/tenant-stamping-phase1
Open

feat(tenancy): capture tenant_id at ingest — Phase 1, capture only#39
DarrenZal wants to merge 2 commits into
stablefrom
darren/tenant-stamping-phase1

Conversation

@DarrenZal

Copy link
Copy Markdown
Contributor

Records which tenant a document belongs to at write time, so per-tenant isolation stays possible later.

⚠️ Read this first

This is capture, not enforcement. Nothing filters on tenant_id. It does not make data safe to expose to an external user — koi-query-api.ts buildPrivacyFilter() still returns '' for any authenticated caller ("Authenticated users see all data"), and access is gated solely by a hardcoded @regen.network email check. A tenant-stamped document is exactly as exposed as an unstamped one.

Deploy order is load-bearing. Run migration 108 before deploying the code. The patched writers name tenant_id in their INSERT column list, so against a pre-108 database every write raises UndefinedColumnError and the row is not written (reproduced: rows written = 0). It fails loudly, but it fails closed. Same in reverse — roll back the code before the migration.

Why now, before any tenancy decision

Provenance not recorded at write time cannot be reconstructed. On prod today:

SELECT count(*) FROM koi_entity_chunk_links l
  LEFT JOIN koi_memories m ON l.document_rid = m.rid WHERE m.rid IS NULL;
-> 200,751 of 3,841,436 (5.22%)

Origin permanently undecidable. Every document ingested without a tenant key joins that set. The column is inert if we never build tenancy, and a prerequisite for any version of it we do build — so it's a cheap hedge rather than a bet on one design.

Scope — three changes

Change Why
migrations/108_tenant_id.sql tenant_id on koi_memories mirroring 015's shape, a CHECK rejecting blanks, two partial indexes
koi_event_bridge_semantic.py The document-level writer (is_chunk FALSE)
koi_event_bridge_v2.py Extends the existing is_private/access_source promotion block (chunk rows)
scripts/doc_scanner.py --tenant, threaded through the full chain including watch mode

Sticky by COALESCE(existing, new): a re-ingest must never silently re-attribute a document that already has an owner.

Deliberately excluded

entity_registry (41 insert sites) and entity_relationships (30) — the two largest write surfaces. entity_registry is a global namespace (UNIQUE (normalized_text, entity_type), resolved with no scope), so adding a tenant key forks every entity per tenant and destroys cross-tenant aggregation. That is a product decision, not a migration. It also means document-level stamping alone does not stop entity-level leakage — worth deciding before anyone relies on this.

Verified against real Postgres, not by inspection

  • migration applies and re-applies cleanly (idempotent, including the CHECK)
  • first ingest sets tenant · re-ingest with a different tenant does not steal the document · NULL re-ingest does not wipe · untenanted doc can be claimed later
  • '' and whitespace-only rejected by the CHECK
  • all touched Python compiles

The blank CHECK exists because COALESCE would treat '' as a real owner and permanently block the correct tenant. It enforces blankness only — stickiness remains a per-writer convention, and an unpatched writer using plain EXCLUDED.tenant_id would silently re-attribute. Stated in the migration.

Second commit fixes five defects an adversarial review found in the first

  1. The down migration sat inside the runner's glob. Both run_migrations.sh and run_migrations_with_backup.sh do ls -1 "$MIGRATION_DIR"/*.sql | sort; the down file matched and sorts after the up, so a runner-driven apply would create the column, immediately drop it, and report Failed: 0. Moved to migrations/down/.
  2. I had patched the wrong bridge site. koi_event_bridge_v2.create_new_version is only ever called with chunk_event — it writes chunk rows. The document writer is in koi_event_bridge_semantic.py and was untouched, so the first commit attributed zero documents.
  3. --tenant was silently dropped for unchanged documentsscan_repo short-circuits before the writer, and --force is rejected in watch mode.
  4. Deploy order was documented nowhere.
  5. The locking note was invertedADD CONSTRAINT ... CHECK takes AccessExclusive and blocks readers; CREATE INDEX takes ShareLock and blocks writers only.

What I'd like reviewed

  • Is tenant_id the right name/shape, given koi_memories already carries is_private + access_source?
  • Should stickiness be a BEFORE UPDATE trigger rather than a per-writer convention?
  • Is excluding entity_registry the right call, or does it make document-level capture not worth having?

🤖 Generated with Claude Code

DarrenZal and others added 2 commits July 31, 2026 15:43
Records WHICH TENANT a document belongs to at write time so per-tenant
isolation stays possible later. This is CAPTURE ONLY — nothing filters on
tenant_id, and it does NOT make data safe to expose to an external user.
koi-query-api.ts buildPrivacyFilter() still returns '' for any authenticated
caller; access is gated solely by a hardcoded @regen.network check.

Why now, before any tenancy decision: provenance not recorded at write time
cannot be reconstructed. On prod today, 200,751 of 3,841,436 rows in
koi_entity_chunk_links (5.22%) have a document_rid resolving to nothing —
origin permanently undecidable. Every document ingested without a tenant key
joins that set.

Scope — three changes, deliberately narrow:
- migration 108: tenant_id on koi_memories, mirroring 015's shape, plus a
  CHECK rejecting blank values and two partial indexes.
- koi_event_bridge_v2: extend the existing is_private/access_source promotion
  block. One site, and every sensor routed through the bridge inherits it.
- doc_scanner: --tenant threaded through the full chain including watch mode,
  since this is the folder-watching path a per-client ingest dir would use and
  the one route where losing attribution is unrecoverable.

Sticky by COALESCE(existing, new): a re-ingest must never silently
re-attribute a document that already has an owner.

Deliberately NOT included: entity_registry (41 insert sites) and
entity_relationships (30). entity_registry is a global namespace —
UNIQUE (normalized_text, entity_type), resolved with no scope — so adding a
tenant key forks every entity per tenant and destroys cross-tenant
aggregation. That is a product decision, not a migration.

Verified against real Postgres, not by inspection:
- migration applies and re-applies cleanly (idempotent, incl. the CHECK)
- first ingest sets tenant
- re-ingest with a DIFFERENT tenant does NOT steal the document
- re-ingest with NULL does NOT wipe an existing tenant
- an untenanted doc CAN still be claimed later (backfill path)
- '' and whitespace-only are rejected by the CHECK

The blank-value CHECK exists because COALESCE would treat '' as a real owner
and permanently block the correct tenant. The Python writers normalise '' to
None, but only 2 of 8 koi_memories writers are patched here, so the invariant
is enforced where every writer must obey it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five confirmed defects from review, all reproduced before fixing.

1. CRITICAL — the down migration sat inside the runner's glob.
   scripts/run_migrations.sh:96 and run_migrations_with_backup.sh:155 both do
   `ls -1 "$MIGRATION_DIR"/*.sql | sort`. 108_tenant_id_down.sql matched, and
   sorts AFTER the up migration on this host's locale — so a runner-driven
   apply would create the column and immediately drop it, recording BOTH as
   applied and reporting "Failed: 0". stable's migrations dir had no down
   files, so this commit introduced the hazard. Moved to migrations/down/.

2. MAJOR — I patched the wrong bridge write site.
   koi_event_bridge_v2.create_new_version has exactly two call sites (:967,
   :969), both passing chunk_event whose rid is f"{bundle.rid}#chunk{i}". That
   INSERT writes CHUNK rows. The document-level writer (is_chunk FALSE) is in
   koi_event_bridge_semantic.py:800 and was untouched — so the previous commit
   did not attribute a single document. Now patched there too. The earlier
   claim that "one site covers every sensor" was wrong.

3. MAJOR — --tenant was silently dropped for unchanged documents.
   scan_repo short-circuits on `existing_hashes.get(rel_path) == chash` before
   reaching upsert_doc, and --force is rejected in watch mode. So every
   already-indexed doc could never be stamped — the exact lost-attribution
   failure this work exists to prevent. get_existing_docs now returns
   tenant_id, and an unchanged doc missing its tenant is no longer skipped.

4. MAJOR — deploy order was load-bearing and documented nowhere.
   Against a pre-108 DB the patched writers raise UndefinedColumnError and
   write ZERO rows (reproduced). Migration now states the ordering in both
   directions.

5. MINOR — the locking note was inverted.
   Measured: ADD CONSTRAINT ... CHECK takes AccessExclusiveLock and blocks
   READERS; CREATE INDEX takes ShareLock and blocks writers only. The note
   claimed the opposite and omitted the statement that actually blocks.
   Also: the size figure should be the 178 MB heap, not the 577 MB total
   relation size. Added lock_timeout = '3s'.

Also: composite index reordered to (tenant_id, superseded_at) — superseded_at
is constant-NULL under the index's own partial predicate, so leading with it
was informationally dead. Removed dead frontmatter precedence in doc_scanner
(a scanned file must not be able to declare its own tenant). Non-str and
over-length tenant values are now coerced rather than raising, so a malformed
payload cannot kill the watch-mode daemon. Corrected the migration's claim
that the CHECK enforces the invariant — it enforces blankness only; stickiness
remains a per-writer convention, and that is now stated.

Re-verified after the fixes: migration applies and re-applies cleanly, sticky
attribution holds, blank rejected, index order confirmed by pg_indexes, all
three touched Python files compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant