Modules, states, and versioned schemas before letting a model propose fields
An operations analyst picks up an old ticket: "reprocess invoice INV-2001 with the new extractor." She runs the command. The function accepts a document_id, finds a record, runs extraction, and returns a plausible result. Except the tenant calling the command is globex; the INV-2001 she meant to touch belongs to acme. Two different organizations, by coincidence or by an adversary, used the same document identifier. If the system's logic is "a document with this ID exists, let me process it," it just processed another tenant's data. This incident is synthetic. It does not describe a customer, a support ticket, or a real leak.
Chapter 1 of this series was about deciding what to measure before picking an extractor. This chapter is about a question that comes before any model: when two organizations share the same product, who decides which document belongs to whom, and does that decision survive an ID collision? The thesis is operational: tenancy, review states, and schema contracts are the part of the system that cannot be delegated to language inference. Trusting the model, the document's own text, or a convenient argument to resolve this turns an ordinary extraction mistake - a wrong field, an unsupported document type - into a cross-organization integrity and privacy incident.
This chapter's artifact evolves RelayOps from chapter 1's brief and CLI into a local modular monolith with four modules (ingest, extraction, review, audit), two versioned JSON Schema contracts, and 16 domain-level tests in examples/. It remains at production zero: no customer, pilot, or real data. What changes is that there is now a testable boundary between "the model proposed this" and "the system accepted this." The pooled-vs-silo tenancy decision is recorded in ADR-004.
null does not distinguish "does not exist" from "is not yours"Before designing the four modules, we fix a rule about storage: a read by tenant_id and document_id that does not belong to the calling tenant returns exactly the same result as a read of a document_id that does not exist anywhere. In examples/src/store.mjs, getOwned(tenantId, documentId) partitions by tenant and returns null in both cases. This is not an implementation detail; it is the difference between denying access and revealing existence. A system that answers "403: document belongs to another tenant" has already leaked a bit of information about another organization. A system that answers "not found" for both cases leaks nothing beyond "this credential has nothing here by that name."
The test that proves this is direct:
const asOwner = store.getOwned('acme', 'INV-2001');
const asAttacker = store.getOwned('globex', 'INV-2001');
const asAttackerUnknownId = store.getOwned('globex', 'INV-9999');
assert.ok(asOwner);
assert.equal(asAttacker, null);
assert.equal(asAttackerUnknownId, null);
assert.equal(asAttacker, asAttackerUnknownId); // indistinguishable: no existence leak
The last line is what matters: the result of trying to read the correct document under a different tenant is identical, bit for bit, to the result of trying to read an ID that never existed. There is no latency difference, message, or response shape a caller could use to infer "this ID exists somewhere." That property drives the rest of the chapter: ingest.mjs, extraction.mjs, and review.mjs all call store.getOwned first and treat null as the only signal of "cannot proceed," never distinguishing "does not exist" from "is not yours" in an error message the caller sees.
The synthetic incident at the start conflates three concepts that RelayOps now separates explicitly:
actorContext.tenantId and actorContext.actorId, passed by the caller - the tutorial stand-in for an authenticated server session. No module accepts a tenant coming from a free-text parameter.review.approve, review.reject, and review.reprocess check not just the tenant but also the revision the reviewer saw (revision in the payload must match the current doc.revision) before accepting a decision - an optimistic-concurrency guard that prevents approving a version that has already changed.store.mjs guarantees by partitioning by tenant_id and never crossing partitions, even when document_id collides.A system can have strong authentication (login works fine) and still fail at authorization (any authenticated user reaches any document) or at isolation (a poorly written query crosses tenants even with correct authorization code). The incident that opens this chapter is an authorization failure: the reprocess command carried a valid authenticated identity (globex), but nothing checked whether that tenant had authority over that specific document_id before acting.
Chapter 1 defined an architecture brief, six quality scenarios, and an invoice parser that accepted a Tenant: acme line inside the document text itself, comparing it against an expected-tenant argument. That was a useful didactic move - showing that a declaration inside the document should not be enough - but it still gave the text a role in the tenant conversation. This chapter removes that role entirely: parseInvoiceText in examples/src/extraction.mjs no longer looks for any tenant line at all. A document's tenant_id is decided once, at ingest time, from actorContext, and never revisited from content.
The data model now has six core fields, defined in examples/contracts/document.schema.json:
tenant_id - ownership identity, never inferred from the document.document_id - an identifier chosen at ingest time; not globally unique, only unique within the tenant (that choice is exactly what makes the opening incident interesting).revision - a number that only advances when a human decision (approve, reject, reprocess) creates a new correct version.source_checksum - a SHA-256 of the original text, computed once at ingest and never recomputed or overwritten.extraction_version - advances every time an extractor (rule-based, or eventually model-based) produces a new proposal for that document.review_status - one of five states: uploaded, extracted, needs_review, approved, rejected.The state machine, in examples/src/state.mjs, is deliberately small:
uploaded -> extracted
extracted -> needs_review
needs_review -> approved | rejected
approved -> extracted (only as reprocessing, with a new revision/extraction_version)
rejected -> extracted (same rule)
Any transition outside that table throws TransitionError. There is a second check, assertReprocessVersioning, that only fires when the transition is approved -> extracted or rejected -> extracted: it requires the new revision to be strictly greater than the previous one, and the new extraction_version to be greater as well. That is what makes this chapter's basic exercise verifiable in code, not just in prose.
The chapter's brief asks for ingest, extraction, review, and audit to be separated "by interfaces and dependencies." In practice that means deciding who is allowed to import whom:
store.mjs, state.mjs, schema-lite.mjs, migrations.mjs, and audit.mjs are leaves: none of them imports another domain module.ingest.mjs imports store.mjs and schema-lite.mjs. It creates the document, computes the checksum, and validates against the contract before writing.extraction.mjs imports store.mjs, state.mjs, and schema-lite.mjs. It reads a document it already owns, proposes fields with a rule-based parser, validates the proposal against extraction.schema.json, and requests two transitions (uploaded -> extracted -> needs_review) using the shared table in state.mjs.review.mjs imports store.mjs and state.mjs. It is the only module authorized to approve, reject, or reprocess.Notice what does not exist: extraction.mjs does not import review.mjs, and review.mjs does not import extraction.mjs. Both depend on the same shared transition table (state.mjs), but not on each other. That is not aesthetics - it is what prevents this chapter's fourth required failure, described below. A simple static check confirms it:
$ grep -n "^import" examples/src/*.mjs
audit.mjs: (no import)
extraction.mjs: store.mjs, state.mjs, schema-lite.mjs
ingest.mjs: store.mjs, schema-lite.mjs
migrations.mjs: (no import)
review.mjs: store.mjs, state.mjs
schema-lite.mjs: (no import)
state.mjs: (no import)
store.mjs: (no import)
This is a real check, executed in this session (see verification.md), not a claim about design intent.
The chapter's prompt asks for a comparison between a well-delimited module and a premature microservice, weighing operational cost. The answer here is concrete: ingest, extraction, review, and audit are separate files with explicit import boundaries and no circular dependency - but they still run in the same Node process, with no queue, no network, no independent deployment. Turning each into a separate HTTP service today would add: a network protocol to version, retries and timeouts on every call between them, a way to propagate actorContext that does not itself become another tenant-spoofing surface, and distributed observability for a system with zero real tenants. None of that is free, and none of it solves a problem RelayOps actually has today. Chapter 1's modularity ADR (ADR-002) already pointed this out; this chapter shows the testable version: separation by import, not by process, until a real measurement - not an aesthetic preference for microservices - shows that a specific module needs to scale, deploy, or run at an uptime independent of the others.
examples/contracts/document.schema.json is version 2 of the document contract. Version 1, frozen in examples/contracts/document.schema.v1.json, has no idempotency_key field; version 2 adds that field as optional. That is the "expand" half of an expand/contract migration: any v1 record becomes a valid v2 record just by filling in idempotency_key: null, without requiring any v1 producer to change first. The migrateDocumentV1toV2 function in examples/src/migrations.mjs does exactly that, and examples/compatibility.test.mjs proves three things with a real v1 fixture (examples/fixtures/document-v1.json):
const on schema_version diverges) - there is no automatic compatibility just because most fields happen to match.migrateDocumentV1toV2, the same document validates against v2, keeps tenant_id and source_checksum identical, and gains idempotency_key: null without losing any prior information.The "contract" half - dropping tolerance for v1, making idempotency_key required - is deliberately not implemented. Contracting before every producer emits v2 would break any caller that has not migrated yet. That is recorded as future work in examples/src/migrations.mjs and in ADR-004, not as a hidden gap.
The most important pedagogical point here is what compatibility.test.mjs tests as an expected failure: a v2 document with review_status: "closed" - a new enum value introduced without versioning the schema - is rejected by the validator. That is the scenario the prompt describes as "a mutable schema without migration": someone tweaks an extraction prompt, or a handler, to start emitting a new status, without touching the contract. If the validator silently accepted it, the system would have an undocumented behavior fork. Rejecting it is the correct behavior; documenting why is this chapter's job.
Symptom: an extractor (rule-based or model-based) processes a document and the field proposal includes, among other things, a tenant_id - either because the text mentioned another organization, or because a tool call decided "this document looks like it's from Globex." If the system uses that field to decide where to write or who can see the result, authorization just became a model opinion.
Cause: confusing "the model may describe something about tenancy" with "the model may decide tenancy." An LLM granted permission to propose an action with an authorization effect is exactly the risk the OWASP GenAI Security Project describes as LLM06:2025, Excessive Agency - an LLM-based system granted a degree of autonomy greater than the task requires (OWASP, LLM Top 10, 2025 edition).
Response: extraction.mjs accepts an optional options.modelProposal (designed for when a model-based extractor exists), and if that object contains tenant_id or tenant, the field is ignored and logged in the audit trail with reason proposal_declared_tenant - never merged into the record. The corresponding test injects an adversarial proposal with tenant_id: 'globex' while the real actorContext is acme, and confirms the resulting document still has tenant_id: 'acme'.
Symptom: a new field, a new enum value, or a type change appears in the data without schema_version changing. Old and new consumers silently disagree about the format.
Cause: treating the schema as documentation rather than as an executable contract - especially easy when the change is born from a prompt tweak ("now the extractor also returns confidence") rather than a deliberate schema decision.
Response: every document writer (ingest.mjs, extraction.mjs) validates against schema-lite.mjs before writing, and any property outside the declared list is rejected (additionalProperties: false). compatibility.test.mjs proves this by injecting model_suggested_tenant as an extra property and confirming the validator rejects it - the same defense that keeps failure 1 from entering through the schema door instead of the parameter door.
Symptom: a reviewer corrects a wrong field, and the correction replaces the original text or the previous revision. After an incident, there is no way to know what the document said before the correction, or who decided what.
Cause: treating "review" as editing a single mutable record, instead of creating a new auditable revision on top of an immutable previous one.
Response: store.putDocument always appends to history (docs.get(documentId).push(...)); it never overwrites a previous entry. review.reprocess creates a new revision with larger revision and extraction_version, but inherits source_checksum from the original document - the source text is written once, at ingest, and never recomputed. The "reprocessing preserves checksum and prior revision" test confirms all three properties at once: reprocessed.source_checksum === uploaded.source_checksum, and store.history('acme', 'INV-2001') still contains the review_status: 'rejected' entry from the previous revision.
Symptom: the team describes the system as "modular architecture" because the code lives in separate files with domain names (ingest.js, review.js...), but extraction imports a function from review to "signal that it's done," and review imports something from extraction to "reprocess automatically." Neither can be understood, tested, or replaced in isolation, because loading one loads the other.
Cause: adding a direct call between domain modules the first time it "would be convenient" for one to call the other, without checking whether that closes a cycle in the dependency graph.
Response: this chapter solved the same coordination problem - extraction needs to advance state, review needs to send it back for reprocessing - without a cycle, by extracting the transition table into state.mjs, a leaf module both import. Neither extraction.mjs nor review.mjs needs to know the other exists. The proof that this holds is not a design claim; it is the static grep reproduced above, which any reader can run again.
The 16 local tests (node --test examples/*.test.mjs, see verification.md for full output) cover: the full positive path (ingest → extract → approve); the opening synthetic incident, with two tenants using the same document_id; cross-tenant read, extraction, and approve/reject attempts denied and audited; a forged tenant_id injected into an extraction proposal; rejecting approved -> extracted without a new version (the basic exercise); preserving checksum and prior revision on reprocessing; a checksum conflict when re-ingesting the same document_id with different content; validating the v1 fixture against its own v1 schema; an expected failure validating v1 directly against v2; a lossless v1-to-v2 migration; rejecting a migration attempt on a document that already claims v2; accepting an explicit idempotency_key; and the two schema failures described above (an unversioned enum, an unknown property).
What this does not prove: no line of PostgreSQL ran in this session. store.mjs correctly partitions within a single Node process using a single Map instance - that is not the same as proving that a row-level security policy secures a shared table under concurrent connections, with a connection pool that could, by mistake, run as the table owner. The next section and the advanced exercise spell out exactly what would be missing. We also did not measure extraction quality: parseInvoiceText is the same class of rule-based parser as chapter 1's baseline, tested here only for contract behavior (does it produce a valid record, does it record provenance), not for accuracy against a real distribution of invoices.
The AWS Well-Architected Framework frames this as questions, not a certification checklist: what security decision does this design assume, and what does it cost to operate? In this chapter, the answer is recorded in ADR-004: keep tenancy pooled (one logical partition per tenant_id, not one schema or database per tenant) and enforce isolation in application code, deferring proof on shared infrastructure.
That carries an explicit reversibility cost. Migrating a specific tenant to its own schema or database later is a data extract-and-reload problem, not a redesign, because tenant_id is already a first-class key from the start. The reverse path - merging tenants that have already diverged back into shared silos - is harder. That favors deferring the silo decision until a real trigger exists: a consented pilot with a contractual data-residency requirement, or a load measurement (not done yet) showing one tenant degrading another's latency in a shared table.
On database security specifically, PostgreSQL's Row Security Policies documentation - reopened in this session, reflecting the "current" docs (PostgreSQL 18 at the time of access) - records three details that change what "proof of isolation" means in practice: ENABLE ROW LEVEL SECURITY must be switched on per table, or the policy simply does not run; superusers and the table owner bypass RLS by default, so a connection pool running as the owner would pass any cross-tenant test while proving nothing; and referential-integrity checks (unique keys, foreign keys) bypass RLS by design, which the documentation itself calls a potential secondary information-leak channel. None of these conditions were tested here - they are documented in ADR-004 as what is missing before any production isolation claim.
tenant_id comes from a caller-authenticated context, never from a field inside the document or a model proposal.Basic. Verifiable criterion: calling review.reprocess(store, auditLog, reviewer, 'INV-2001', { nextRevision: currentRevision, reason: '...' }) from the approved state, using the same current revision (without incrementing it), must throw a TransitionError whose message contains "new revision," and the audit log must contain an entry with result: 'denied' and reason: 'missing_versioning'. Commented solution: this is already implemented and tested in examples/tenancy.test.mjs, in the test "basic exercise: approved -> extracted is rejected without a new revision/extraction_version" - the check lives in assertReprocessVersioning (examples/src/state.mjs), called by review.reprocess before any write to the store.
Intermediate. Verifiable criterion: add a v2 schema for the document contract without invalidating the existing v1 fixture. Commented solution: already solved by examples/contracts/document.schema.v1.json (frozen) + examples/contracts/document.schema.json (v2, with optional idempotency_key) + migrateDocumentV1toV2 in examples/src/migrations.mjs. The "intermediate exercise" test in examples/compatibility.test.mjs runs exactly that migration over examples/fixtures/document-v1.json and confirms nothing is lost. Demonstrative snippet (not a new command - the one already run in this session): node --test examples/compatibility.test.mjs.
Advanced. Verifiable criterion: write a test that attempts to read, by a known document_id, a document belonging to another tenant, and describe - without implementing - the additional requirements to prove the same property against a real PostgreSQL instance. Commented solution: the test already exists ('cross-tenant read of a known document_id returns nothing, same as a nonexistent id', in examples/tenancy.test.mjs) and proves the property inside a single Node process using one Map instance. The additional requirements, described in examples/adrs/004-tenancy.md and not executed in this session, are: (1) ALTER TABLE ... ENABLE ROW LEVEL SECURITY explicitly turned on for the table; (2) a policy such as USING (tenant_id = current_setting('app.tenant_id')::text) - demonstrative snippet, not executed:
-- Demonstrative. Not executed in this session; requires a real Postgres instance and a dedicated role.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::text);
(3) a test role that is neither the table owner nor has BYPASSRLS, since the owner and superuser bypass RLS by default; (4) two separate transactions with different SET LOCAL app.tenant_id = '...' values, testing cross-tenant SELECT/UPDATE/DELETE and expecting zero rows or a permission error; (5) attention to the secondary channel PostgreSQL itself documents: unique/foreign-key violations bypass RLS and could, in principle, reveal that a row exists in another partition.
This chapter proves isolation and contract behavior inside a single Node process. What remains explicitly open for the rest of the series: a real integration with a transactional database (ADR-004 already lists the RLS requirements that need to be satisfied, not just stated); a retention and PII-redaction policy more complete than the current redactFields, which today only masks string length; and the first point where a real model-based extractor - not the parseInvoiceText rule - enters the system without inheriting any authority over tenancy or state transitions. None of those three items is implemented here; they are recorded as the next experiment, not as a result.
Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares
Um document_id válido pode atravessar organização se autorização depender do texto ou do modelo. O capítulo 2 constrói tenancy, estados e contratos versionados para o RelayOps.
Start AI-native architecture with testable constraints. Build an offline baseline, measurable quality scenarios, and reversible decisions for RelayOps.
A microservices diagram appears before any operational measurement. The final AI-native Architect chapter uses virtual-time simulation to test per-tenant serving order, records the trade-off, and…
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.