feat(tenancy): capture tenant_id at ingest — Phase 1, capture only - #39
Open
DarrenZal wants to merge 2 commits into
Open
feat(tenancy): capture tenant_id at ingest — Phase 1, capture only#39DarrenZal wants to merge 2 commits into
DarrenZal wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Records which tenant a document belongs to at write time, so per-tenant isolation stays possible later.
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.tsbuildPrivacyFilter()still returns''for any authenticated caller ("Authenticated users see all data"), and access is gated solely by a hardcoded@regen.networkemail 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_idin their INSERT column list, so against a pre-108 database every write raisesUndefinedColumnErrorand 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:
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
migrations/108_tenant_id.sqltenant_idonkoi_memoriesmirroring 015's shape, a CHECK rejecting blanks, two partial indexeskoi_event_bridge_semantic.pyis_chunk FALSE)koi_event_bridge_v2.pyis_private/access_sourcepromotion block (chunk rows)scripts/doc_scanner.py--tenant, threaded through the full chain including watch modeSticky 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) andentity_relationships(30) — the two largest write surfaces.entity_registryis 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
''and whitespace-only rejected by the CHECKThe blank CHECK exists because
COALESCEwould 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 plainEXCLUDED.tenant_idwould silently re-attribute. Stated in the migration.Second commit fixes five defects an adversarial review found in the first
run_migrations.shandrun_migrations_with_backup.shdols -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 reportFailed: 0. Moved tomigrations/down/.koi_event_bridge_v2.create_new_versionis only ever called withchunk_event— it writes chunk rows. The document writer is inkoi_event_bridge_semantic.pyand was untouched, so the first commit attributed zero documents.--tenantwas silently dropped for unchanged documents —scan_reposhort-circuits before the writer, and--forceis rejected in watch mode.ADD CONSTRAINT ... CHECKtakes AccessExclusive and blocks readers;CREATE INDEXtakes ShareLock and blocks writers only.What I'd like reviewed
tenant_idthe right name/shape, givenkoi_memoriesalready carriesis_private+access_source?entity_registrythe right call, or does it make document-level capture not worth having?🤖 Generated with Claude Code