diff --git a/database/migrations/0024_document_record_idempotent_persistence.sql b/database/migrations/0024_document_record_idempotent_persistence.sql new file mode 100644 index 000000000..cc8be50a4 --- /dev/null +++ b/database/migrations/0024_document_record_idempotent_persistence.sql @@ -0,0 +1,394 @@ +-- Bind one purpose-scoped idempotency key to one semantic document persistence +-- command and its first committed result. The advisory lock covers only the +-- database transaction that checks replay state and writes the immutable fact; +-- no external computation or network work occurs while it is held. + +-- Capability-role names are security boundaries. Reusing an existing cluster +-- role could retain memberships or object ACLs that CREATE ROLE cannot erase. +-- Fail before changing any project object so a collision cannot leave partial +-- persistence migration state behind. +DO $orgmetra_document_persistence_role_preflight$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles + WHERE rolname IN ( + 'orgmetra_document_persistence_owner', + 'orgmetra_document_persistence_executor' + ) + ) THEN + RAISE EXCEPTION 'pre-existing document persistence capability role is not accepted' + USING ERRCODE = '42710'; + END IF; +END; +$orgmetra_document_persistence_role_preflight$; + +BEGIN; + +SET LOCAL search_path = public, pg_catalog; + +ALTER TABLE document_record + ADD CONSTRAINT document_record_tenant_identity_unique + UNIQUE (tenant_record_id, document_record_id); + +CREATE TABLE document_record_persist_receipt ( + tenant_record_id uuid NOT NULL REFERENCES tenant_record(tenant_record_id), + idempotency_key text NOT NULL, + semantic_command_digest_sha256 text NOT NULL, + document_record_id uuid NOT NULL, + receipt_digest_sha256 text NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT pg_catalog.transaction_timestamp(), + + CONSTRAINT document_record_persist_idempotency_key_check + CHECK ( + idempotency_key ~ + '^document-record-persist-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT document_record_persist_semantic_digest_check + CHECK (semantic_command_digest_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT document_record_persist_receipt_digest_check + CHECK (receipt_digest_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT document_record_persist_receipt_command_unique + UNIQUE (tenant_record_id, idempotency_key), + CONSTRAINT document_record_persist_receipt_document_unique + UNIQUE (tenant_record_id, document_record_id), + CONSTRAINT document_record_persist_receipt_document_fk + FOREIGN KEY (tenant_record_id, document_record_id) + REFERENCES document_record(tenant_record_id, document_record_id) +); + +COMMENT ON TABLE document_record_persist_receipt IS + 'Append-only, tenant-scoped replay receipt for document_records persistence. It stores only an opaque purpose-bound key, semantic command digest, committed document identity, and receipt digest; document bytes and free-form HR content are excluded.'; + +CREATE TRIGGER document_record_persist_receipt_append_only_guard +BEFORE UPDATE OR DELETE ON document_record_persist_receipt +FOR EACH ROW +EXECUTE FUNCTION public.reject_append_only_mutation(); + +CREATE FUNCTION public.reject_document_record_persist_receipt_truncate() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +BEGIN + RAISE EXCEPTION 'document-record persistence receipts are append-only and cannot be truncated' + USING ERRCODE = '55000'; +END; +$$; + +CREATE TRIGGER document_record_persist_receipt_truncate_guard +BEFORE TRUNCATE ON document_record_persist_receipt +FOR EACH STATEMENT +EXECUTE FUNCTION public.reject_document_record_persist_receipt_truncate(); + +REVOKE TRUNCATE ON document_record_persist_receipt FROM PUBLIC; + +ALTER TABLE document_record_persist_receipt ENABLE ROW LEVEL SECURITY; +ALTER TABLE document_record_persist_receipt FORCE ROW LEVEL SECURITY; + +CREATE POLICY document_record_persist_receipt_tenant_policy +ON document_record_persist_receipt +USING (tenant_record_id = public.current_tenant_record_id()) +WITH CHECK (tenant_record_id = public.current_tenant_record_id()); + +CREATE FUNCTION public.persist_document_record_once( + p_tenant_record_id uuid, + p_idempotency_key text, + p_document_record_id uuid, + p_document_record_reference text, + p_person_record_reference text, + p_employment_record_reference text, + p_uploader_actor_reference text, + p_persisted_by_actor_reference text, + p_document_category_code text, + p_artifact_reference text, + p_artifact_digest_sha256 text, + p_source_provenance_digest_sha256 text, + p_retention_policy_reference text, + p_retention_policy_digest_sha256 text, + p_received_at timestamptz, + p_canonical_evidence_json text, + p_evidence_digest_sha256 text, + p_audit_event_reference text, + p_outbox_event_reference text, + p_application_evidence_digest_sha256 text +) +RETURNS TABLE ( + document_record_id uuid, + document_record_reference text, + audit_event_reference text, + outbox_event_reference text, + semantic_command_digest_sha256 text, + receipt_digest_sha256 text, + recorded_at timestamptz +) +LANGUAGE plpgsql +VOLATILE +SET search_path = pg_catalog, public, pg_temp +SET TimeZone = 'UTC' +AS $$ +DECLARE + v_semantic_command_digest text; + v_existing_semantic_digest text; + v_document_recorded_at timestamptz; + v_receipt_digest text; +BEGIN + IF pg_catalog.current_setting('transaction_isolation') IS DISTINCT FROM 'read committed' THEN + RAISE EXCEPTION 'document persistence idempotency requires read committed transaction isolation' + USING ERRCODE = '25000'; + END IF; + + IF p_tenant_record_id IS NULL + OR p_idempotency_key IS NULL + OR p_document_record_id IS NULL + OR p_document_record_reference IS NULL + OR p_person_record_reference IS NULL + OR p_employment_record_reference IS NULL + OR p_uploader_actor_reference IS NULL + OR p_persisted_by_actor_reference IS NULL + OR p_document_category_code IS NULL + OR p_artifact_reference IS NULL + OR p_artifact_digest_sha256 IS NULL + OR p_source_provenance_digest_sha256 IS NULL + OR p_retention_policy_reference IS NULL + OR p_retention_policy_digest_sha256 IS NULL + OR p_received_at IS NULL + OR p_canonical_evidence_json IS NULL + OR p_evidence_digest_sha256 IS NULL + OR p_audit_event_reference IS NULL + OR p_outbox_event_reference IS NULL + OR p_application_evidence_digest_sha256 IS NULL THEN + RAISE EXCEPTION 'document persistence command cannot contain null authoritative fields' + USING ERRCODE = '22004'; + END IF; + + IF public.current_tenant_record_id() IS DISTINCT FROM p_tenant_record_id THEN + RAISE EXCEPTION 'document persistence tenant context does not match requested tenant' + USING ERRCODE = '42501'; + END IF; + + IF p_idempotency_key !~ + '^document-record-persist-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' THEN + RAISE EXCEPTION 'document persistence idempotency key must be one opaque purpose-bound UUID reference' + USING ERRCODE = '22023'; + END IF; + + v_semantic_command_digest := pg_catalog.encode( + public.digest( + pg_catalog.convert_to( + pg_catalog.jsonb_build_object( + 'schema_version', 'orgmetra.document_record_persist_command.v1', + 'tenant_record_id', p_tenant_record_id::text, + 'document_record_id', p_document_record_id::text, + 'document_record_reference', p_document_record_reference, + 'person_record_reference', p_person_record_reference, + 'employment_record_reference', p_employment_record_reference, + 'uploader_actor_reference', p_uploader_actor_reference, + 'persisted_by_actor_reference', p_persisted_by_actor_reference, + 'document_category_code', p_document_category_code, + 'artifact_reference', p_artifact_reference, + 'artifact_digest_sha256', p_artifact_digest_sha256, + 'source_provenance_digest_sha256', p_source_provenance_digest_sha256, + 'retention_policy_reference', p_retention_policy_reference, + 'retention_policy_digest_sha256', p_retention_policy_digest_sha256, + 'received_at', p_received_at, + 'canonical_evidence_json', p_canonical_evidence_json, + 'evidence_digest_sha256', p_evidence_digest_sha256, + 'audit_event_reference', p_audit_event_reference, + 'outbox_event_reference', p_outbox_event_reference, + 'application_evidence_digest_sha256', p_application_evidence_digest_sha256, + 'application_purpose_code', 'document_record_persist', + 'application_reason_code', 'reviewed_document_metadata' + )::text, + 'UTF8' + ), + 'sha256' + ), + 'hex' + ); + + -- Match the established People mutation pattern: hold a transaction-scoped + -- key lock only around replay lookup plus the authoritative database write. + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended( + p_tenant_record_id::text || E'\\x1fdocument_records\\x1f' || p_idempotency_key, + 0 + ) + ); + + SELECT receipt.semantic_command_digest_sha256 + INTO v_existing_semantic_digest + FROM public.document_record_persist_receipt AS receipt + WHERE receipt.tenant_record_id = p_tenant_record_id + AND receipt.idempotency_key = p_idempotency_key; + + IF FOUND THEN + IF v_existing_semantic_digest IS DISTINCT FROM v_semantic_command_digest THEN + RAISE EXCEPTION 'idempotency key is bound to a different document persistence command' + USING ERRCODE = '23514'; + END IF; + + RETURN QUERY + SELECT + persisted.document_record_id, + persisted.document_record_reference, + persisted.audit_event_reference, + persisted.outbox_event_reference, + receipt.semantic_command_digest_sha256, + receipt.receipt_digest_sha256, + persisted.recorded_at + FROM public.document_record_persist_receipt AS receipt + JOIN public.document_record AS persisted + ON persisted.tenant_record_id = receipt.tenant_record_id + AND persisted.document_record_id = receipt.document_record_id + WHERE receipt.tenant_record_id = p_tenant_record_id + AND receipt.idempotency_key = p_idempotency_key; + RETURN; + END IF; + + INSERT INTO public.document_record ( + tenant_record_id, + document_record_id, + document_record_reference, + person_record_reference, + employment_record_reference, + uploader_actor_reference, + persisted_by_actor_reference, + document_category_code, + artifact_reference, + artifact_digest_sha256, + source_provenance_digest_sha256, + retention_policy_reference, + retention_policy_digest_sha256, + received_at, + canonical_evidence_json, + evidence_digest_sha256, + audit_event_reference, + outbox_event_reference, + application_evidence_digest_sha256, + application_purpose_code, + application_reason_code + ) VALUES ( + p_tenant_record_id, + p_document_record_id, + p_document_record_reference, + p_person_record_reference, + p_employment_record_reference, + p_uploader_actor_reference, + p_persisted_by_actor_reference, + p_document_category_code, + p_artifact_reference, + p_artifact_digest_sha256, + p_source_provenance_digest_sha256, + p_retention_policy_reference, + p_retention_policy_digest_sha256, + p_received_at, + p_canonical_evidence_json, + p_evidence_digest_sha256, + p_audit_event_reference, + p_outbox_event_reference, + p_application_evidence_digest_sha256, + 'document_record_persist', + 'reviewed_document_metadata' + ) + RETURNING document_record.recorded_at INTO v_document_recorded_at; + + v_receipt_digest := pg_catalog.encode( + public.digest( + pg_catalog.convert_to( + pg_catalog.jsonb_build_object( + 'schema_version', 'orgmetra.document_record_persist_receipt.v1', + 'tenant_record_id', p_tenant_record_id::text, + 'idempotency_key', p_idempotency_key, + 'semantic_command_digest_sha256', v_semantic_command_digest, + 'document_record_id', p_document_record_id::text, + 'document_record_reference', p_document_record_reference, + 'audit_event_reference', p_audit_event_reference, + 'outbox_event_reference', p_outbox_event_reference, + 'document_recorded_at', v_document_recorded_at + )::text, + 'UTF8' + ), + 'sha256' + ), + 'hex' + ); + + INSERT INTO public.document_record_persist_receipt ( + tenant_record_id, + idempotency_key, + semantic_command_digest_sha256, + document_record_id, + receipt_digest_sha256, + recorded_at + ) VALUES ( + p_tenant_record_id, + p_idempotency_key, + v_semantic_command_digest, + p_document_record_id, + v_receipt_digest, + v_document_recorded_at + ); + + RETURN QUERY + SELECT + p_document_record_id, + p_document_record_reference, + p_audit_event_reference, + p_outbox_event_reference, + v_semantic_command_digest, + v_receipt_digest, + v_document_recorded_at; +END; +$$; + +-- PostgreSQL grants EXECUTE on newly created functions to PUBLIC by default. +-- Revoke that ambient capability before transferring this write boundary to a +-- dedicated NOLOGIN owner. The externally assignable executor gets only +-- schema USAGE + function EXECUTE and therefore cannot bypass replay semantics +-- through direct table DML. +REVOKE EXECUTE ON FUNCTION public.persist_document_record_once( + uuid, text, uuid, text, text, text, text, text, text, text, text, text, + text, text, timestamptz, text, text, text, text, text +) FROM PUBLIC; + +CREATE ROLE orgmetra_document_persistence_owner + NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; +CREATE ROLE orgmetra_document_persistence_executor + NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; + +GRANT USAGE ON SCHEMA public + TO orgmetra_document_persistence_owner, orgmetra_document_persistence_executor; +GRANT SELECT, INSERT ON TABLE public.document_record + TO orgmetra_document_persistence_owner; +GRANT SELECT, INSERT ON TABLE public.document_record_persist_receipt + TO orgmetra_document_persistence_owner; +GRANT EXECUTE ON FUNCTION public.current_tenant_record_id() + TO orgmetra_document_persistence_owner; +GRANT EXECUTE ON FUNCTION public.digest(bytea, text) + TO orgmetra_document_persistence_owner; + +-- ALTER FUNCTION OWNER requires CREATE on the containing schema for the target +-- owner. Grant it only for this ownership handoff, then revoke it before commit. +GRANT CREATE ON SCHEMA public TO orgmetra_document_persistence_owner; +ALTER FUNCTION public.persist_document_record_once( + uuid, text, uuid, text, text, text, text, text, text, text, text, text, + text, text, timestamptz, text, text, text, text, text +) OWNER TO orgmetra_document_persistence_owner; +ALTER FUNCTION public.persist_document_record_once( + uuid, text, uuid, text, text, text, text, text, text, text, text, text, + text, text, timestamptz, text, text, text, text, text +) SECURITY DEFINER; +REVOKE CREATE ON SCHEMA public FROM orgmetra_document_persistence_owner; +GRANT EXECUTE ON FUNCTION public.persist_document_record_once( + uuid, text, uuid, text, text, text, text, text, text, text, text, text, + text, text, timestamptz, text, text, text, text, text +) TO orgmetra_document_persistence_executor; + +COMMENT ON FUNCTION public.persist_document_record_once( + uuid, text, uuid, text, text, text, text, text, text, text, text, text, + text, text, timestamptz, text, text, text, text, text +) IS + 'Persists one immutable document-record fact and replay receipt under a tenant-scoped transaction advisory lock. The SECURITY DEFINER function is owned by a dedicated NOLOGIN/NOBYPASSRLS role with only SELECT/INSERT on document persistence tables; the externally assignable executor role has EXECUTE only and cannot bypass replay semantics with direct DML. The caller tenant context must match the requested tenant before any replay lock or durable write. The owner fails closed outside Read Committed because replay visibility relies on a fresh post-lock statement snapshot. Same-key same-semantic retries return the first committed result; changed semantics fail closed. Digest serialization uses function-local UTC so equivalent timestamptz values do not change replay identity across caller sessions.'; + +COMMIT; diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 7d4afd563..c3d4ac891 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -19,6 +19,8 @@ | `candidate_worker_link` | Legacy append-only candidate-to-worker linkage retained for historical reads; new writes use `candidate_worker_conversion_record`. | | `candidate_worker_conversion_record` | Governed bitemporal candidate-to-worker conversion bound to the hire decision, person, employment, immutable audit event, and outbox evidence. | | `people_mutation_idempotency_record` | Append-only tenant/route/idempotency-key binding to the canonical command digest and first committed created-record identity for governed People writes. | +| `document_record` | Immutable, tenant-scoped document metadata/evidence snapshot with opaque Person/Employment, artifact, audit, and outbox references; it never stores raw document bytes or free-form HR content. | +| `document_record_persist_receipt` | Append-only tenant-scoped idempotency receipt binding one persistence key and semantic-command digest to the first committed `document_record` identity, receipt digest, and database-owned recorded time. | | `criterion_blueprint` | Job-related performance criterion definition. | | `criterion_observation` | Observed criterion result. | | `decision_evidence_set` | Versioned evidence-set header whose database-computed digest and membership are sealed by one accountable selection decision. | @@ -66,6 +68,16 @@ New predictive-validity membership uses `validity_study_case_record` rather than The owning write port acquires an exact-key transaction-scoped advisory lock and writes the HRIS fact, immutable audit/outbox evidence, and idempotency row inside one PostgreSQL transaction. A rolled-back mutation therefore cannot leave a false replay marker. The relation is append-only, TRUNCATE-protected, tenant-RLS isolated, and uses opaque operational UUIDs. The idempotency key is transport correlation, not HR data or authorization evidence; actor, purpose, human-confirmation and resource authorization remain independently required. +## Document-record persistence and idempotency + +`document_record` is the immutable document-metadata system of record for the `document_records` bounded context. It stores only governed metadata and evidence: tenant identity, opaque Person/Employment references, category, uploader/persisting actor references, artifact/source/retention/evidence/application SHA-256 digests, the exact bounded canonical evidence JSON, opaque audit/outbox correlations, business `received_at`, and PostgreSQL-owned `recorded_at`. Raw document bytes, title/free-form HR text, compensation, ratings, credentials, and employment-decision output are excluded from this relation. + +`document_record_persist_receipt` is the append-only uncertain-retry authority for that persistence command. Its tenant-qualified business key is `(tenant_record_id, idempotency_key)`. The row binds the opaque idempotency key to the canonical semantic-command digest, the first committed `document_record_id`, the immutable document/audit/outbox references returned by that command, a receipt digest, and the database-owned `recorded_at`. The receipt does not duplicate document bytes, Person/Employment attributes, or other free-form HR content. + +First execution writes one `document_record` and one receipt in the same PostgreSQL transaction. A same-key/same-semantic retry returns the original committed identity and original database-owned recorded time without creating another document fact. Reusing the key for a different semantic command fails closed before another document write. Concurrent first attempts serialize on a tenant-qualified transaction-scoped advisory lock. The owner function requires exact tenant context before acquiring that coordination state, runs only under `READ COMMITTED`, canonicalizes digest timestamps in UTC, and is exposed to the application executor as an EXECUTE-only `SECURITY DEFINER` capability; the executor has no direct document/receipt table DML authority. + +Both relations are tenant-scoped with forced row-level security and tenant-qualified referential integrity. Receipt UPDATE, DELETE, and TRUNCATE are rejected. This receipt proves creation/retry convergence only; return/destruction completion and recovery-aware deletion remain separate `document_records` lifecycle authority and must not be inferred from a persistence receipt. + ## Audit and outbox normalization `audit_event_record`, `outbox_delivery_record`, and `outbox_delivery_escalation_record` are deliberately separate relations. The audit relation stores the immutable, PII-minimized canonical CloudEvents representation and its SHA-256 digest. The database allowlists the event shape, verifies event and tenant identifiers, requires accountable human confirmation when `data.high_impact` is true, and recomputes the digest over the exact stored UTF-8 text before accepting the row. diff --git a/docs/ERD.md b/docs/ERD.md index a547cc3a0..8bd9c0bb8 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -1,12 +1,14 @@ # ERD -For readability, the diagram renders representative `tenant_record` scoping edges rather than repeating the same edge for every tenant-owned relation. The authoritative tenant-isolation contract is `docs/DATA_MODEL.md`: **every owned HRIS fact** stores `tenant_record_id`, every cross-table reference is tenant-qualified, and forced row-level security applies independently to every tenant-scoped table. This omission is visual only; it does not weaken the relational or authorization contract for employment, candidate, evidence, decision, validation-link, compensation, transition, audit, outbox, or outbox-escalation entities. +For readability, the diagram renders representative `tenant_record` scoping edges rather than repeating the same edge for every tenant-owned relation. The authoritative tenant-isolation contract is `docs/DATA_MODEL.md`: **every owned HRIS fact** stores `tenant_record_id`, every cross-table reference is tenant-qualified, and forced row-level security applies independently to every tenant-scoped table. This omission is visual only; it does not weaken the relational or authorization contract for employment, candidate, document, evidence, decision, validation-link, compensation, transition, audit, outbox, or outbox-escalation entities. ```mermaid erDiagram tenant_record ||--o{ person_record : scopes tenant_record ||--o{ organization_unit : scopes tenant_record ||--o{ job_profile : scopes + tenant_record ||--o{ document_record : scopes + tenant_record ||--o{ document_record_persist_receipt : scopes tenant_record ||--o{ audit_event_record : scopes tenant_record ||--o{ outbox_delivery_escalation_record : scopes person_record ||--o{ person_name_record : has_names @@ -23,6 +25,7 @@ erDiagram position_record ||--o{ assignment_record : assigned_through candidate_profile ||--o| candidate_worker_link : may_become person_record ||--o{ candidate_worker_link : links_worker + document_record ||--o| document_record_persist_receipt : may_have_first_commit_receipt job_profile ||--o{ criterion_blueprint : requires performance_cycle ||--o{ criterion_observation : schedules criterion_blueprint ||--o{ criterion_observation : produces @@ -53,6 +56,8 @@ Every owned HRIS fact carries `tenant_record_id`. Relationships that cross table A candidate profile can be linked to at most one worker identity within its tenant. A person identity can have multiple candidate-worker links across reapplications or historical candidate profiles, so the person-side cardinality is one-to-many. +A `document_record` is one immutable document-metadata fact owned by `document_records`. It deliberately keeps Person, Employment, artifact, audit, and outbox correlations as opaque owner-contract references rather than cross-context foreign keys. A persistence command executed through `persist_document_record_once(...)` creates exactly one `document_record_persist_receipt` with its first committed document fact. The receipt has tenant-qualified uniqueness on both `(tenant_record_id, idempotency_key)` and `(tenant_record_id, document_record_id)`, and the latter pair is a foreign key to the document fact, so each receipt identifies exactly one document and each document can have at most one receipt. Migration 0024 does not backfill or require receipts for document rows that already existed before the idempotent write port, so the dataset-level document-to-receipt cardinality is zero-or-one. The receipt stores only replay identity/digests and database-owned time; it does not duplicate document bytes or free-form HR data. + Each criterion observation belongs to one effective-dated performance cycle so reporting periods remain reconstructable across effective and system time. A high-impact selection decision seals exactly one versioned `decision_evidence_set`. Evidence members are inserted while the set is open; the decision records the set reference and atomically changes that set to sealed. After sealing, neither new evidence members nor a second decision may reuse that evidence set. This prevents post-decision evidence drift while retaining normalized, version-addressable evidence. diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 31f3ff23e..fbe4bd94d 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -38,9 +38,25 @@ - A successful dead-letter transition clears lease metadata, preserves the terminal failure code, moves the queue row to `dead_lettered`, and records tenant-scoped immutable escalation evidence. The escalation row is append-only and cannot be rewritten to make an operational failure disappear. - Dead-lettered rows are excluded from normal claiming. Recovery requires an explicit new business/operator action and must not mutate or reopen the terminal historical row. - Stable dispatcher worker references remain operational identities. The operator path exists only for the narrower expired-and-exhausted failure mode and does not permit takeover of live or retryable work. -- Audit/outbox SQL boundaries pin `search_path` to `pg_catalog, public, pg_temp`, the migration revokes `CREATE` on `public` from `PUBLIC`, and project objects remain in the trusted application schema until schema extraction work explicitly moves them. Normal dispatcher/persistence functions remain security-invoker boundaries; the lost-final-worker recovery function is the sole `SECURITY DEFINER` exception and is owned by the hardened NOLOGIN recovery role rather than a login or superuser role. +- Audit/outbox SQL boundaries pin `search_path` to `pg_catalog, public, pg_temp`, the migration revokes `CREATE` on `public` from `PUBLIC`, and project objects remain in the trusted application schema until schema extraction work explicitly moves them. Ordinary dispatcher/persistence functions remain security-invoker boundaries unless an ADR proves an execute-only capability cannot be preserved that way. The lost-final-worker recovery function and document-record uncertain-retry persistence function are current narrow `SECURITY DEFINER` exceptions; each is owned by its own hardened NOLOGIN/NOBYPASSRLS role rather than a login or superuser role, and its externally assignable executor/operator role has no direct DML on the protected tables. - Exponential/backoff policy selection, policy-specific producer configuration, and external delivery receipts remain release blockers before reliable asynchronous delivery is called production-ready; terminal dead-letter/escalation evidence and lost-final-worker recovery are implemented but do not by themselves prove downstream receipt. +### Document-record persistence under uncertain retry + +- `persist_document_record_once(...)` is the `document_records` owner boundary for an uncertain-outcome metadata-persistence retry. One transaction binds the immutable `document_record` fact to one append-only `document_record_persist_receipt`; a generic uniqueness violation, elapsed time, or connection loss is never interpreted as success evidence. +- The callable boundary is execute-only. Migration 0024 rejects pre-existing reserved capability-role names before project mutation, creates `orgmetra_document_persistence_owner` and `orgmetra_document_persistence_executor` as NOLOGIN/NOBYPASSRLS roles, transfers the function to the restricted owner as `SECURITY DEFINER`, removes temporary schema `CREATE` before commit, and grants the executor function EXECUTE but no direct table DML. Production login roles obtain this capability only through explicit purpose-bound membership provisioning. A role-name collision is an operator-review failure, not a reason to reuse unknown role state. +- The caller must establish `orgmetra.tenant_record_id` and it must exactly match `p_tenant_record_id` before semantic digest calculation, replay lookup, advisory-lock acquisition, or durable write. FORCE RLS remains active for the dedicated function owner and defense in depth rather than permission to acquire another tenant's database-global coordination state. +- The retry algorithm is intentionally restricted to PostgreSQL `READ COMMITTED`. It relies on a fresh statement snapshot after acquiring the transaction-scoped advisory lock so a waiter can observe the winner's committed receipt. `REPEATABLE READ` and `SERIALIZABLE` calls fail closed rather than pretending that stale-snapshot replay recovery is safe. +- The transaction-scoped advisory lock covers only replay lookup plus authoritative database writes. OCR, LLM work, object-store transfer, network calls, long-running calculation, and other external work must complete before entering this transaction boundary; no idle external wait is allowed while the lock is held. +- The semantic command digest is tenant- and purpose-bound and includes the governed persistence inputs. Function-local UTC serialization prevents equivalent `timestamptz` instants from acquiring different replay identities because of caller session timezone. +- Same tenant/key plus the same semantic digest returns the originally committed document identity, audit/outbox references, semantic digest, receipt digest, and database-owned `recorded_at`. The replay result does not mint fresh authoritative identities or timestamps. Same tenant/key with a different semantic digest fails closed as a conflicting replay. +- After a transport failure where commit outcome is unknown, recovery resubmits the same purpose-bound idempotency key and the exact same semantic command. A new key is a new command identity and must not be used merely to escape uncertainty. Operators escalate a conflicting replay instead of deleting or rewriting the original receipt. +- The receipt is append-only, FORCE-RLS protected, and PII-minimized: it carries the opaque idempotency identity, semantic digest, document identity, receipt digest, and original database time rather than document bytes or free-form HR content. +- Concurrency acceptance must observe the second PostgreSQL backend actually blocked on the first backend's advisory lock and must prove both callers converge to one durable document and one receipt. Fixed sleeps are not serialization evidence. +- Capability acceptance must prove the owner role has only the table privileges required by the function, the executor has none of those table DML privileges, direct table access as the executor is denied, and function execution still reaches reviewed command validation. A generic role with schema usage only must remain unable to execute the function. +- Recovery evidence is incomplete if a test client, transaction, or temporary security principal survives the acceptance run. Test-only principals use collision-resistant per-run identities; normal completion verifies strict cleanup, while failure cleanup remains best-effort so it cannot mask the original assertion. +- This retry receipt proves initial metadata persistence only. Return/destruction completion and recovery-invisibility evidence belong to the separate `document_records` lifecycle authority tracked by #308 and must not be inferred from this receipt. + ### Other dependencies - Psychometrics Commons unavailable: assessment-result fetches show an unavailable state, not invented scores. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fd6dd3ea6..b6695fabf 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,6 +25,7 @@ - A dispatcher lease is an executable capability. Completion and retry require the exact owner of a still-live lease under the active tenant context. Before retry-budget exhaustion, expired ownership must be reclaimed through the guarded claim path; after exhaustion, claim/retry cannot create attempt N+1 and only the exact recorded stable worker reference may use the normal worker terminalization path. If that recorded final worker identity is permanently unavailable, a separately provisioned purpose-bound operator capability may terminalize only an already-expired exhausted lease through the audited recovery function; the operator role has no direct outbox read/write or escalation-insert privilege. - Terminal dead-lettering additionally requires an immutable database-owned retry budget, durable exhaustion of that budget, a bounded failure classification, and matching opaque escalation evidence. A dispatcher cannot lower the threshold at finalization, structurally valid direct terminal DML cannot omit the evidence/budget invariant, a foreign worker cannot steal an exhausted row, a dead-lettered row cannot silently re-enter normal dispatch, and its escalation evidence cannot be updated or deleted. - Privileged outbox recovery role names are fail-closed deployment identities. Migration 0008 rejects either reserved role name if it already exists instead of inheriting unknown memberships or ACLs; fresh NOLOGIN/NOBYPASSRLS roles are created only after that preflight. The temporary `CREATE` privilege needed for function ownership transfer is granted and revoked inside one transaction, so an interrupted handoff cannot strand schema-creation authority. +- Document-record uncertain-retry persistence is also an explicit database capability. Migration 0024 rejects pre-existing reserved persistence-role names before changing project objects, owns `persist_document_record_once(...)` with a dedicated NOLOGIN/NOBYPASSRLS `SECURITY DEFINER` role, and grants the externally assignable executor only schema usage plus function `EXECUTE`. The executor receives no direct `SELECT`, `INSERT`, `UPDATE`, `DELETE`, or `TRUNCATE` privilege on `document_record` or `document_record_persist_receipt`, so a service role cannot bypass semantic-digest comparison, advisory-lock serialization, tenant-before-lock validation, or receipt creation with direct DML. The function owner receives schema `CREATE` only for the ownership handoff and loses it before commit. - Credentials and passkeys remain in Keyverse or external secret managers. - Service database roles cannot query another service's application tables. - Client error responses expose a random `support_reference`, never an internal trace/span identifier or encoded infrastructure context. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index c20813b72..a0ff5a75f 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -31,12 +31,13 @@ The command runs Python repository-integrity validation, the dependency-free Nod | Predictive-validity study case worker/decision/evidence/criterion and recorded-time integrity | `bash tests/test_validity_study_case_postgres.sh` against PostgreSQL 16 in Foundation CI | | Performance criterion observation Job, cycle, staffing, current-recorded-time, and UTC date-boundary integrity | `bash tests/test_criterion_observation_scope_postgres.sh` against PostgreSQL 16 in Foundation CI | | Governed People mutation idempotency: tenant/route/key uniqueness, identical-command replay, changed-command rejection, rollback safety, append-only/TRUNCATE protection, forced RLS and concurrent exact-key serialization | `bash tests/test_people_mutation_idempotency_postgres.sh` against PostgreSQL 16 in Foundation CI | +| Governed `document_records` uncertain-retry persistence: purpose-bound idempotency receipt, exact semantic replay, conflicting replay rejection, database-owned result-time recovery, real advisory-lock wait graph, FORCE-RLS behavior, tenant-before-lock enforcement, unsupported-isolation fail-close, timezone-stable digest/result identity, connection cleanup, failure-safe temporary-principal cleanup, and an execute-only SECURITY-DEFINER capability whose executor has no direct document/receipt table DML | `bash tests/test_document_record_idempotency_postgres.sh`, `bash tests/test_document_record_idempotency_function_acl_postgres.sh`, `bash tests/test_document_record_idempotency_tenant_context_postgres.sh`, and `bash tests/test_document_record_idempotency_isolation_postgres.sh` against PostgreSQL 16 through owner-neutral Foundation discovery | | Tenant/actor/purpose authorization matrix and negative high-impact commands | service-specific unit and integration test commands recorded in each service package | | AsyncAPI/CloudEvents envelope compatibility | provider and consumer contract test commands recorded beside the versioned event schema | | External adapter timeout, malformed response, tenant mismatch, and unavailable-state handling | fake-server tests in each adapter package | | Role-workspace keyboard, focus, exact-value, permission-denied, and confirmation states | Storybook interaction/a11y tests plus browser E2E for the owning workspace | -The PostgreSQL scripts apply the checked-in migration chain required by the contract under test to a fresh database. The bitemporal and evidence-sealing tests execute concurrency regressions with an observable database barrier instead of a fixed scheduling assumption. The tenant-isolation test proves both read and write enforcement with unprivileged `NOLOGIN NOBYPASSRLS` roles, so table-owner/superuser bypass cannot manufacture a passing tenant result. The evidence-sealing test compares database output with independently precomputed canonical SHA-256 fixtures and forces a membership transaction to hold the evidence-set row lock before finalization, proving the digest snapshot includes evidence that committed first. The audit/outbox contract stores exact `AuditOutboxEvent.canonical_json()` bytes, independently verifies their SHA-256 digest in PostgreSQL, rejects extra top-level PII fields even when a caller recomputes the digest, and exercises outbox lifecycle invariants separately from immutable audit facts. The outbox-claim contract proves an already-expired lease cannot be created, verifies deterministic tenant-scoped claims return the immutable event/digest while live leases are excluded, then lets a valid one-second lease expire and requires atomic takeover of that same row with attempt count 2, a new future lease, and explicit `lease_expired` evidence. The dead-letter contract applies migrations 0001 through 0007, proves the dispatcher cannot select its own terminal attempt budget, rejects direct terminal DML before matching immutable escalation evidence and the stored budget are satisfied, exercises the real retry/claim path through the database-owned default fifth attempt, rejects retry at attempt five, lets the final lease expire, proves a replacement worker cannot create attempt six, proves the row remains bound to the recorded worker identity, rejects a foreign finalizer, permits that exact recorded identity to append terminal evidence after expiry, and rejects fabricated escalation evidence for nonterminal work. The People mutation idempotency contract applies the authoritative migration chain through 0012, verifies the replay record is written in the same transaction as its authoritative fact and audit/outbox evidence, proves rollback leaves no false replay marker, and uses concurrent exact-key sessions to prove one canonical committed identity wins without duplicate business facts. Foundation CI executes every matrix entry independently; a cancelled, skipped, queued, absent, neutral, failed, stale, predecessor-head, status-only, or model-only matrix result is not database evidence for the current head. +The PostgreSQL scripts apply the checked-in migration chain required by the contract under test to a fresh database. The bitemporal and evidence-sealing tests execute concurrency regressions with an observable database barrier instead of a fixed scheduling assumption. The tenant-isolation test proves both read and write enforcement with unprivileged `NOLOGIN NOBYPASSRLS` roles, so table-owner/superuser bypass cannot manufacture a passing tenant result. The evidence-sealing test compares database output with independently precomputed canonical SHA-256 fixtures and forces a membership transaction to hold the evidence-set row lock before finalization, proving the digest snapshot includes evidence that committed first. The audit/outbox contract stores exact `AuditOutboxEvent.canonical_json()` bytes, independently verifies their SHA-256 digest in PostgreSQL, rejects extra top-level PII fields even when a caller recomputes the digest, and exercises outbox lifecycle invariants separately from immutable audit facts. The outbox-claim contract proves an already-expired lease cannot be created, verifies deterministic tenant-scoped claims return the immutable event/digest while live leases are excluded, then lets a valid one-second lease expire and requires atomic takeover of that same row with attempt count 2, a new future lease, and explicit `lease_expired` evidence. The dead-letter contract applies migrations 0001 through 0007, proves the dispatcher cannot select its own terminal attempt budget, rejects direct terminal DML before matching immutable escalation evidence and the stored budget are satisfied, exercises the real retry/claim path through the database-owned default fifth attempt, rejects retry at attempt five, lets the final lease expire, proves a replacement worker cannot create attempt six, proves the row remains bound to the recorded worker identity, rejects a foreign finalizer, permits that exact recorded identity to append terminal evidence after expiry, and rejects fabricated escalation evidence for nonterminal work. The People mutation idempotency contract applies the authoritative migration chain through 0012, verifies the replay record is written in the same transaction as its authoritative fact and audit/outbox evidence, proves rollback leaves no false replay marker, and uses concurrent exact-key sessions to prove one canonical committed identity wins without duplicate business facts. The document-record idempotency contracts apply the governed document-record chain through migration 0024. They require the requested tenant to match active tenant context before any semantic digest, replay lookup, advisory lock, or write; prove same-key/same-semantic UTC↔Asia/Seoul retries recover the original database-owned `recorded_at`; reject changed semantics; reject `REPEATABLE READ` before command work because replay visibility depends on a fresh post-lock Read Committed statement snapshot; and observe the second backend holding an ungranted advisory lock with `pg_blocking_pids(...)` naming the first before allowing the first transaction to commit. FORCE-RLS is exercised with a temporary `NOBYPASSRLS` non-superuser rather than inferred only from catalog flags. The function-ACL contract separately proves the canonical owner/executor roles are NOLOGIN/NOBYPASSRLS, the function is owned by the restricted role as `SECURITY DEFINER`, temporary schema `CREATE` was removed, the owner has only required table SELECT/INSERT, the executor has EXECUTE but no table DML, direct executor table access fails, an executor function call reaches reviewed command validation, and a generic run-unique role cannot execute the function. The probe identity is collision-resistant per run, normal-path cleanup is strict, failure cleanup is best-effort so it cannot hide the original assertion, and the concurrency clients must leave no PostgreSQL sessions behind. Foundation admission remains owner-neutral: the contract registry/discovery owner must include these roots without adding a filename switch. Foundation CI executes every matrix entry independently; a cancelled, skipped, queued, absent, neutral, failed, stale, predecessor-head, status-only, or model-only matrix result is not database evidence for the current head. Future service packages must publish their exact test, statement-coverage, branch-coverage, docstring, typecheck, and build commands in the package manifest and CI log. @@ -51,6 +52,10 @@ Required negative and provenance tests include: - a reused confirmation or idempotency key cannot bind to different command content; - an identical tenant/route/idempotency-key retry replays the first committed created-record identity rather than issuing a duplicate authoritative write; - concurrent exact-key requests serialize at the persistence boundary and cannot commit two different identities; +- `document_records` persistence must bind active tenant context before database-global advisory coordination, recover same-key/same-semantic uncertain retries to the exact original receipt/result, and reject same-key changed semantics rather than treating a uniqueness error or elapsed time as success evidence; +- the document-record retry path must fail closed outside Read Committed, and real concurrency acceptance must observe PostgreSQL's advisory wait graph rather than rely on a scheduling delay; +- document-record retry evidence must return the original database-owned time and remain invariant across caller session timezones; test-only RLS principals and concurrent clients must be fully cleaned up on a passing run; +- the document-record application-facing database capability must be function EXECUTE only: its hardened NOLOGIN/NOBYPASSRLS function owner may hold the minimum direct SELECT/INSERT needed by the SECURITY-DEFINER implementation, while the executor role must have no direct persistence-table DML and no schema CREATE; - previewed evidence versions must equal recorded evidence versions; - an open evidence set rejects a caller-supplied digest, preventing a client assertion from masquerading as database-observed membership; - finalizing a selection decision requires at least one versioned evidence member, computes the canonical SHA-256 digest in PostgreSQL, and seals exactly one evidence set in the same transaction; diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 29d11a478..86473e3d3 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -5,12 +5,12 @@ | Threat | Example | Preventive control | Detection evidence and required test | |---|---|---|---| | Spoofing | External identity treated as HR person identity | Separate `person_record` from the Keyverse subject; verify issuer, audience, tenant, actor binding, and token lifetime. | Authentication-denial audit; tests reject subject/person substitution and stale authorization. | -| Tampering | Selection evidence, audit envelope, terminal escalation evidence, or delivery history changed after decision | Append-only decision/evidence records; database-sealed evidence digest; append-only `audit_event_record` and `outbox_delivery_escalation_record`; immutable database-owned delivery retry budget; database recomputation of SHA-256 over exact canonical audit bytes; guarded delivery state stored separately. | Integrity alert; tests reject update/delete, digest mismatch, added audit fields, illegal outbox state changes, retry-budget mutation/dispatcher override, attempt N+1, terminal-row reopening, escalation mutation/fabrication, and version mismatch. | -| Repudiation | Hiring manager or integration operator denies a decision or terminal delivery failure | Human confirmation reference, actor, tenant, purpose, reason, evidence versions, immutable audit event, recorded dispatcher identity, and immutable terminal escalation reference with durable attempt count. | Correlated decision/audit/escalation lookup; tests prove actor traceability, prohibit high-impact audit persistence without confirmation, and preserve one append-only escalation record for dead-lettered work. | -| Information disclosure | PII broadcast, over-broad field response, or retained data through an event bus or failure queue | Opaque references, exact durable audit-field allowlist, purpose-bound tenant/resource/operation/scope/field authorization before protected values leave the HR boundary, tenant-scoped encryption, and no copied mutable HR payload in audit or escalation evidence. | Payload scanner plus authorization/database contracts; tests reject extra employee-name/PII event fields, foreign-resource access, missing operation scope, disallowed fields, and malformed or wildcard-like authorization attributes while constraining escalation metadata to governance codes/references. | -| Cross-tenant access | Tenant A reads, reconstructs, changes, or emits evidence for Tenant B HRIS facts by altering a path, header, reference, cache key, event, delivery, escalation, or by supplying a foreign fact with a colliding durable identifier to an in-memory decision. | Authenticated tenant context, explicit agreement among request/actor/resource/policy tenants, explicit tenant scope in historical reconstruction and HRIS decision functions, audit event/tenant identity binding, forced RLS on audit/outbox/escalation relations, service-owned database roles, tenant-aware cache keys, and consumer-side event validation. | `cross_tenant_access_denied` audit event with no sensitive values; authorization/integration/kernel/database tests attempt request/actor/resource tenant mismatches, direct reads/writes, object-reference swaps, colliding identifiers, event tenant mismatch, foreign delivery finalization/escalation, cache poisoning, and replay across tenants and require denial or exclusion with unchanged target data. | -| Denial of service | Integration retries flood services, a permanently rejected event loops forever, or final-attempt ownership is lost | Idempotency, guarded outbox leasing, immutable bounded database-owned retry-attempt budget with terminal dead-letter removal, no claim/retry after exhaustion, stable final-attempt worker identity, bounded exponential backoff and queue limits before production dispatcher release, circuit breaking, and per-tenant budgets. | Queue-depth/lease/retry/dead-letter telemetry; load and recovery tests must prove bounded work, pre-exhaustion expired-lease recovery, stored retry-budget exhaustion, no attempt N+1, exhausted-final-lease non-reclaimability, recorded-owner terminalization, and fair tenant isolation before dispatcher release. | -| Elevation of privilege | A purpose header, broad token, LLM, or foreign dispatcher grants itself access or finalizes another worker's high-impact/transport state | Purpose-bound authorization requires exact tenant/resource/purpose/operation matching, operation-specific Keyverse scope, and field minimization; LLM outputs remain draft evidence; human confirmation controls HR writes; dispatcher completion/retry require exact live lease ownership; dead-lettering requires exhausted durable budget plus the exact recorded final-attempt worker identity, and never accepts a dispatcher-selected retry threshold. | Authorization-denial audit; tests reject purpose-only authorization, missing scopes, disallowed fields, cross-tenant resources, LLM decision records, missing-confirmation audit events, `Offered`/`Worker` transitions, foreign/stale completion or retry, dispatcher retry-budget override, direct premature terminal DML, replacement-worker claim after exhaustion, foreign dead-lettering, fabricated nonterminal escalation evidence, and premature dead-lettering. | +| Tampering | Selection evidence, audit envelope, terminal escalation evidence, delivery history, or a document-persistence retry receipt changed after decision | Append-only decision/evidence records; database-sealed evidence digest; append-only `audit_event_record`, `outbox_delivery_escalation_record`, and `document_record_persist_receipt`; immutable database-owned delivery retry budget; database recomputation of SHA-256 over exact canonical audit/document evidence bytes; guarded delivery state stored separately. | Integrity alert; tests reject update/delete/TRUNCATE, digest mismatch, added audit fields, illegal outbox state changes, retry-budget mutation/dispatcher override, attempt N+1, terminal-row reopening, escalation mutation/fabrication, document-receipt rewrite, changed-command idempotency replay, and version mismatch. | +| Repudiation | Hiring manager or integration operator denies a decision, terminal delivery failure, or committed document-metadata write after the client lost the response | Human confirmation reference, actor, tenant, purpose, reason, evidence versions, immutable audit event, recorded dispatcher identity, immutable terminal escalation reference with durable attempt count, and an immutable tenant-scoped document persistence receipt that returns the first committed identity/time on exact replay. | Correlated decision/audit/escalation/receipt lookup; tests prove actor traceability, prohibit high-impact audit persistence without confirmation, preserve one append-only escalation record for dead-lettered work, and recover one authoritative document result after observed commit plus caller connection loss. | +| Information disclosure | PII broadcast, over-broad field response, or retained data through an event bus, failure queue, or retry receipt | Opaque references, exact durable audit-field allowlist, purpose-bound tenant/resource/operation/scope/field authorization before protected values leave the HR boundary, tenant-scoped encryption, and no copied mutable HR payload in audit, escalation, or idempotency-receipt evidence. | Payload scanner plus authorization/database contracts; tests reject extra employee-name/PII event fields, foreign-resource access, missing operation scope, disallowed fields, and malformed or wildcard-like authorization attributes while constraining escalation and replay receipts to governance codes/references/digests. | +| Cross-tenant access | Tenant A reads, reconstructs, changes, emits evidence for, or acquires retry coordination over Tenant B HRIS facts by altering a path, header, reference, cache key, event, delivery, escalation, idempotency key, or by supplying a foreign fact with a colliding durable identifier to an in-memory decision. | Authenticated tenant context, explicit agreement among request/actor/resource/policy tenants, explicit tenant scope in historical reconstruction and HRIS decision functions, tenant-before-advisory-lock validation for document retry, audit event/tenant identity binding, forced RLS on audit/outbox/escalation/document-receipt relations, service-owned database roles, tenant-aware cache keys, and consumer-side event validation. | `cross_tenant_access_denied` audit event with no sensitive values; authorization/integration/kernel/database tests attempt request/actor/resource tenant mismatches, direct reads/writes, object-reference swaps, colliding identifiers, event tenant mismatch, foreign delivery finalization/escalation, cross-tenant retry-lock acquisition, cache poisoning, and replay across tenants and require denial or exclusion with unchanged target data. | +| Denial of service | Integration retries flood services, a permanently rejected event loops forever, a final-attempt owner disappears, or a caller intentionally holds another tenant's retry key | Idempotency, guarded outbox leasing, immutable bounded database-owned retry-attempt budget with terminal dead-letter removal, no claim/retry after exhaustion, stable final-attempt worker identity, tenant validation before database-global advisory locks, short database-only retry transactions, bounded exponential backoff and queue limits before production dispatcher release, circuit breaking, and per-tenant budgets. | Queue-depth/lease/retry/dead-letter/advisory-wait telemetry; load and recovery tests must prove bounded work, pre-exhaustion expired-lease recovery, stored retry-budget exhaustion, no attempt N+1, exhausted-final-lease non-reclaimability, recorded-owner terminalization, foreign-tenant retry denial before lock acquisition, and fair tenant isolation before dispatcher release. | +| Elevation of privilege | A purpose header, broad token, LLM, foreign dispatcher, or database service role grants itself access, bypasses idempotency with direct DML, or finalizes another worker's high-impact/transport state | Purpose-bound authorization requires exact tenant/resource/purpose/operation matching, operation-specific Keyverse scope, and field minimization; LLM outputs remain draft evidence; human confirmation controls HR writes; dispatcher completion/retry require exact live lease ownership; dead-lettering requires exhausted durable budget plus the exact recorded final-attempt worker identity; document uncertain-retry persistence uses a restricted NOLOGIN/NOBYPASSRLS `SECURITY DEFINER` owner while the externally assignable executor has function EXECUTE but no direct persistence-table DML or schema CREATE. | Authorization/ACL-denial evidence; tests reject purpose-only authorization, missing scopes, disallowed fields, cross-tenant resources, LLM decision records, missing-confirmation audit events, `Offered`/`Worker` transitions, foreign/stale completion or retry, dispatcher retry-budget override, direct premature terminal DML, replacement-worker claim after exhaustion, foreign dead-lettering, fabricated nonterminal escalation evidence, premature dead-lettering, generic function execution, executor direct document-table access, over-privileged function ownership, and reserved-role-name reuse. | ## Model risk @@ -21,3 +21,5 @@ LLM analysis may summarize, extract, or draft but cannot publish job profiles, a Blanket masking can break HR work. Orgmetra uses purpose-bound access, encryption, retention, audit, and export control so authorized users can work with required PII safely. The purpose-bound decision evaluates request, authenticated actor, resource, and policy tenant identity before resource/purpose/operation/scope/field attributes; decisions retain governance metadata and field names rather than protected field values. Event and telemetry surfaces minimize PII even when the authoritative service is permitted to display it. `audit_event_record` keeps only the allowlisted governance envelope; delivery retries, leases, immutable retry budget, and terminal state live in `outbox_delivery_record`; immutable dead-letter escalation metadata lives in `outbox_delivery_escalation_record`. This separation prevents mutable transport coordination or terminal operator evidence from rewriting or expanding retained audit evidence. Exhausted final-attempt ownership remains bound to the recorded stable worker reference under the normal worker path; if that identity is permanently unavailable, the implemented `orgmetra_outbox_operator` capability may invoke only the guarded expired-final-lease recovery function while remaining unable to read/update outbox rows or insert escalation evidence directly. + +`document_record_persist_receipt` stores only tenant identity, an opaque purpose-scoped retry key, semantic/result digests, the committed document identity, and database time. The callable write surface is `persist_document_record_once(...)`; its dedicated function owner can perform only the required document/receipt SELECT/INSERT under FORCE RLS, while `orgmetra_document_persistence_executor` can invoke that function but cannot read or mutate either persistence table directly. Reserved owner/executor role-name collisions fail before project mutation instead of reusing unknown cluster ACL state. diff --git a/docs/adr/0309-document-record-idempotent-persistence.md b/docs/adr/0309-document-record-idempotent-persistence.md new file mode 100644 index 000000000..0417c4469 --- /dev/null +++ b/docs/adr/0309-document-record-idempotent-persistence.md @@ -0,0 +1,104 @@ +# ADR 0309: Idempotent document-record persistence under uncertain outcomes + +## Status + +Proposed. This ADR describes the stacked implementation for issue #309 and is not protected-`develop` truth until its prerequisites and this change integrate normally. + +## Problem + +ADR 0107 and migrations 0021–0023 make one `document_record` immutable, tenant-scoped, evidence-bound, unique-key JSON safe, and byte-canonical. Those invariants prevent duplicate durable identities from being silently accepted, but a uniqueness error is not an idempotent result contract. + +A caller can lose the response after PostgreSQL commits. If it retries the same logical persistence command, the owner must distinguish that retry from a different command that reused the same key. Treating a generic unique violation as success would conflate those cases; generating new audit/outbox references on every retry would also make one logical command appear as multiple durable events. + +The lock boundary must remain short. Document parsing, OCR, model inference, artifact transfer, or any other network/compute work is not permitted inside the database transaction used for retry arbitration. Tenant identity must also be verified before the function acquires any advisory lock; RLS at the eventual table write is too late because advisory locks are database-global coordination state rather than row-scoped state. + +The callable database boundary must also preserve the same invariant. A `SECURITY INVOKER` function cannot be treated as an execute-only persistence capability if a future service principal also needs direct `SELECT`/`INSERT` on the underlying tables to make the function work: those privileges would let that principal bypass the replay receipt and call path entirely. + +## Decision + +`document_records` owns a tenant-scoped `document_record_persist_receipt` and the `persist_document_record_once(...)` transaction boundary. + +The command accepts one opaque, purpose-bound idempotency key of the form `document-record-persist-`. It computes a server-side SHA-256 semantic digest over the complete governed persistence command, excluding only PostgreSQL-owned result time. The key itself is not part of the semantic digest; it identifies a retry family rather than changing document semantics. + +PostgreSQL grants `EXECUTE` on newly created functions to `PUBLIC` by default. Migration 0024 revokes that ambient grant and establishes two reserved NOLOGIN/NOBYPASSRLS roles after a fail-before-mutation collision preflight. `orgmetra_document_persistence_owner` owns `persist_document_record_once(...)` as a `SECURITY DEFINER` function and receives only schema usage plus the `SELECT`/`INSERT` and helper-function privileges required to implement the transaction. `orgmetra_document_persistence_executor` receives schema usage and `EXECUTE` on that exact function signature, but no direct document or receipt table privilege. The owner receives schema `CREATE` only long enough to complete the ownership handoff and loses it before the migration commits. Production login identities may acquire the executor capability only through explicit purpose-bound membership provisioning; migration execution requires authority to create the two fresh service-owned roles. + +This is a narrow capability boundary, not a general preference for `SECURITY DEFINER`. The function pins `search_path`, the definer is NOLOGIN/NOBYPASSRLS and is not the table owner, FORCE RLS remains active, and direct DML remains unavailable to the externally assignable executor. The design follows the repository's existing hardened operator-recovery pattern rather than granting the application role both function execution and bypass-capable table DML. + +After null-authoritative-field validation, the function requires `current_tenant_record_id()` to equal `p_tenant_record_id`. A mismatch fails with SQLSTATE `42501` before semantic digest computation, replay lookup, advisory-lock acquisition, or any durable write. This explicit owner check remains required even though both document and receipt tables use FORCE RLS: a privileged migration/test connection can bypass RLS, and an advisory lock can otherwise be acquired for another tenant before row security is evaluated. + +Only after that tenant check does the function acquire `pg_advisory_xact_lock(hashtextextended(...))` over tenant + `document_records` namespace + key. The lock exists only until the current transaction ends. A same-key concurrent caller therefore waits until the first transaction commits or rolls back. No external I/O occurs while this lock is held. + +After the lock: + +- no receipt means the function inserts exactly one `document_record`, derives a receipt digest from the committed identity/result, and inserts one append-only receipt in the same transaction; +- an existing receipt with the same semantic digest returns the original document identity, document/audit/outbox references, semantic digest, receipt digest, and original database-owned `recorded_at`; +- an existing receipt with a different semantic digest fails closed before any second document write. + +The receipt carries no document bytes, credentials, names, free-form HR content, compensation, ratings, or other duplicated Person/Employment truth. It stores only tenant identity, the opaque idempotency key, semantic digest, committed document identity, receipt digest, and system time. It is FORCE-RLS protected and append-only, including TRUNCATE protection. + +The implementation adds a tenant-qualified unique key to `document_record` so the receipt can use a composite `(tenant_record_id, document_record_id)` foreign key. This preserves the bounded-context invariant that a receipt cannot point at a document from another tenant even if an otherwise valid UUID is supplied. + +## Alternatives considered + +**Return success on a unique violation.** Rejected. A uniqueness violation does not prove that the existing row came from the same semantic command. + +**Retry heuristics in `talent_acquisition` or another consumer.** Rejected. Persistence replay truth belongs to `document_records`; copying mutable owner logic would create two authorities. + +**Rely on PostgreSQL's default `PUBLIC` function EXECUTE privilege.** Rejected. `persist_document_record_once(...)` is a write capability for restricted HR metadata, not a cluster-wide utility. Authentication and tenant checks inside the function do not replace least-privilege admission to the function itself, and ambient defaults must not silently widen the callable persistence surface. + +**Keep the function `SECURITY INVOKER` and grant a service principal the table privileges it needs.** Rejected. The service principal would then be able to insert or read persistence tables directly and bypass the exact idempotency/replay boundary that the function is intended to own. Execute-only admission requires a separate hardened function owner with the table privileges and a caller role with no direct DML. + +**Make the migration or login role the `SECURITY DEFINER` owner.** Rejected. A login or cluster-powerful migration role would make function compromise materially broader. The dedicated owner is NOLOGIN, NOBYPASSRLS, denied schema CREATE after handoff, and receives only the object privileges required by this one capability. + +**Rely on table RLS to reject a mismatched tenant after lock acquisition.** Rejected. Row security protects table access, not database-global advisory-lock ownership. A mismatched request must be rejected before it can coordinate on another tenant's retry key, and privileged maintenance connections must not silently bypass the bounded-context tenant invariant. + +**Hold an explicit transaction open around upstream document processing.** Rejected. That would create the long-lived idle/lock behavior this architecture forbids. All expensive work must finish before entering `persist_document_record_once(...)`. + +**Rely only on `INSERT ... ON CONFLICT`.** Rejected for this increment because the owner must compare a complete semantic digest and return the original result as one contract, not merely suppress a duplicate insert. The transaction-scoped advisory lock follows the already-protected People mutation pattern and makes the replay branch explicit. + +## Evidence and acceptance + +`tests/test_document_record_idempotency_postgres.sh` exercises real PostgreSQL sessions. It proves same-key/same-semantic retry convergence, same-key/different-semantic rejection, concurrent same-semantic convergence while the first transaction remains open, one durable document + one receipt, connection cleanup, receipt FORCE RLS, and append-only mutation rejection. The same command is also executed first under UTC and then under Asia/Seoul; digest identity must remain unchanged because the owner function canonicalizes its temporal serialization to UTC. All supported calls now provide the tenant session context explicitly instead of relying on a privileged test owner. Because the function executes as the dedicated NOBYPASSRLS owner, this root also exercises the actual write path under the definer's restricted table grants and FORCE-RLS policy rather than succeeding only through the migration superuser. + +`tests/test_document_record_idempotency_function_acl_postgres.sh` verifies both sides of the capability boundary. It requires the canonical owner and executor roles to be NOLOGIN/NOSUPERUSER/NOCREATEDB/NOCREATEROLE/NOREPLICATION/NOBYPASSRLS, requires the function to be `SECURITY DEFINER` and owned by `orgmetra_document_persistence_owner`, requires schema `CREATE` to be absent after handoff, and requires the owner to have only `SELECT`/`INSERT` on the document and receipt tables. The executor must have function `EXECUTE` and schema usage but zero direct `SELECT`/`INSERT`/`UPDATE`/`DELETE`/`TRUNCATE` privilege on those tables. A behavioral `SET ROLE` probe must be denied a direct table read while an EXECUTE-only function call reaches reviewed command validation. A separate run-unique unprivileged role must still fail at function authorization, proving `PUBLIC` EXECUTE remains revoked. + +`tests/test_document_record_idempotency_tenant_context_postgres.sh` supplies a valid tenant-beta command while the session tenant is tenant-alpha and requires the explicit owner error `document persistence tenant context does not match requested tenant`. It also proves that the rejected attempt leaves zero beta document and receipt rows. This contract intentionally remains valid even when the Foundation database owner can bypass RLS, because the owner function itself must enforce the tenant boundary before advisory-lock acquisition. + +PostgreSQL 16 documents `pg_advisory_xact_lock` as an exclusive transaction-level advisory lock that waits when necessary and is automatically released at transaction end. The function is explicitly `VOLATILE`; PostgreSQL's function-volatility contract gives volatile functions a fresh snapshot for each query they execute under the ordinary Read Committed transaction model. That fresh post-lock lookup is what lets a waiting retry observe the first transaction's committed receipt rather than reinterpret a uniqueness error as success. + +PostgreSQL 16 also documents that newly created functions receive `EXECUTE` for `PUBLIC` by default and recommends revoking that privilege in the same transaction when a function is not intended for every database role. Migration 0024 follows that boundary explicitly rather than relying on cluster-specific `ALTER DEFAULT PRIVILEGES` state. The dedicated owner/executor split then ensures the eventual application-facing principal can invoke the transaction without acquiring the underlying table privileges that would permit an alternate write path. + +The owner function checks `transaction_isolation` before validating or mutating command state and fails closed unless it is `read committed`. `tests/test_document_record_idempotency_isolation_postgres.sh` enters a real `REPEATABLE READ` transaction and requires that isolation error before any command-field validation. Stronger isolation levels therefore cannot silently inherit semantics that depend on a fresh post-lock statement snapshot; a future successor must supply an explicit equivalent algorithm before relaxing this guard. + +The expired IETF HTTPAPI `Idempotency-Key` Internet-Draft is non-normative background only. Its key principles—one client-generated key for retries and no key reuse with a different payload—are compatible with this design, but the draft expired on 2026-04-18 and is not cited as an active standard. + +## Risks + +Advisory-lock hash collisions can serialize unrelated commands, although they cannot merge their receipt state because the durable key remains tenant + exact idempotency key. The consequence is unnecessary waiting, not cross-command success. The explicit tenant-context guard prevents a caller from intentionally acquiring this coordination state for a different tenant through `persist_document_record_once(...)`. + +The semantic digest is versioned as `orgmetra.document_record_persist_command.v1`. Any future change to governed command membership requires a new schema version and migration; silently changing digest membership would break deterministic replay interpretation. + +A caller that abandons a connection mid-transaction relies on PostgreSQL rollback/connection cleanup. Acceptance therefore checks that concurrent test sessions terminate; production pooling/TLS/connection-recovery policy remains an operability concern at the future document-record service adapter. + +The explicit Read Committed guard intentionally rejects a caller that promotes this one operation to Repeatable Read or Serializable without a successor design. That is a compatibility boundary, not an invitation to weaken isolation elsewhere: the future adapter must scope transaction policy to this documented write contract. + +The two reserved capability-role names are cluster-level security state. A pre-existing role with either name causes migration 0024 to fail before it mutates project objects rather than attempting to reuse unknown memberships or ACLs. This means deployment migration authority must include role creation, and operators must investigate rather than rename around a collision. + +`SECURITY DEFINER` increases the importance of the fixed search path, narrow owner grants, FORCE RLS, and the executor's lack of direct table access. Any future helper called from the function must be schema-qualified and must not expand the owner's privilege set without a corresponding executable ACL regression. + +## Follow-up + +- Admit `tests/test_document_record_idempotency_postgres.sh`, `tests/test_document_record_idempotency_function_acl_postgres.sh`, `tests/test_document_record_idempotency_isolation_postgres.sh`, and `tests/test_document_record_idempotency_tenant_context_postgres.sh` through the owner-neutral PostgreSQL Foundation registry once #310/#311 is reconciled with the document-record stack; do not add a feature-local workflow. +- Add the application/service adapter only after the `document_records` service boundary exists. Its authenticated login role must obtain purpose-bound membership in `orgmetra_document_persistence_executor`; it must not receive direct table DML or reimplement replay logic. +- Re-run the full PostgreSQL acceptance on the exact protected-base head before changing this ADR from Proposed. +- Keep #308 return/destruction completion receipts separate: persistence idempotency proves creation/retry identity, not later retention or destruction completion. + +## References + +Jena, J., & Dalal, S. (2025, October 15). *The Idempotency-Key HTTP Header Field* (Internet-Draft draft-ietf-httpapi-idempotency-key-header-07, expired April 18, 2026). Internet Engineering Task Force. https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/ + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: Advisory lock functions*. https://www.postgresql.org/docs/16/functions-admin.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: Function volatility categories*. https://www.postgresql.org/docs/16/xfunc-volatility.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: Privileges*. https://www.postgresql.org/docs/16/ddl-priv.html diff --git a/docs/traceability/document-record-idempotent-persistence.md b/docs/traceability/document-record-idempotent-persistence.md new file mode 100644 index 000000000..dcc21de47 --- /dev/null +++ b/docs/traceability/document-record-idempotent-persistence.md @@ -0,0 +1,66 @@ +# Document-record idempotent persistence traceability + +Status: active stacked evidence for #309/#312. This file is not protected-`develop` truth until the stack integrates normally. + +| Requirement | Owner artifact | Executable evidence | Current state | +| --- | --- | --- | --- | +| One retry family has one tenant-scoped opaque key | `document_record_persist_receipt.idempotency_key` in migration 0024 | invalid/different-key behavior is constrained by the migration; #312 review remains pending | Implemented on Draft head | +| Persistence is an execute-only purpose-bound capability rather than an alternate direct-DML path | migration 0024 revokes `PUBLIC` EXECUTE, rejects pre-existing reserved role names before project mutation, transfers `persist_document_record_once(...)` to `orgmetra_document_persistence_owner` as `SECURITY DEFINER`, grants that NOLOGIN/NOBYPASSRLS owner only required `SELECT`/`INSERT` + helper execution, removes temporary schema `CREATE`, and grants `orgmetra_document_persistence_executor` only schema usage + function EXECUTE | `tests/test_document_record_idempotency_function_acl_postgres.sh` requires exact role attributes/function ownership/`prosecdef`, owner least privilege, executor zero table DML, behavioral denial of executor direct table access, behavioral EXECUTE-only entry into reviewed function validation, and denial for a run-unique generic probe role | Implemented; hosted execution pending Foundation admission | +| Session tenant must match requested tenant before retry coordination | explicit `current_tenant_record_id() = p_tenant_record_id` guard before semantic digest/replay lock | `tests/test_document_record_idempotency_tenant_context_postgres.sh` submits a valid tenant-beta command from a tenant-alpha session, requires SQLSTATE-42501 boundary text, and proves zero beta document/receipt rows | Implemented; hosted execution pending Foundation admission | +| Same key + same semantic command returns the first committed result | `persist_document_record_once(...)` semantic digest + replay branch | `tests/test_document_record_idempotency_postgres.sh` compares first and retry identity, audit/outbox references, semantic/receipt digests, and the original database-owned `recorded_at` instant; it then extracts the returned epoch and requires that exact value to equal both durable `document_record.recorded_at` and `document_record_persist_receipt.recorded_at` | Implemented; hosted execution pending Foundation admission | +| Same key + changed semantics fails closed | server-side `orgmetra.document_record_persist_command.v1` SHA-256 | PostgreSQL contract changes only `application_evidence_digest_sha256` and requires the explicit semantic-conflict error | Implemented; hosted execution pending | +| Retry identity is independent of caller session timezone | function-local `SET TimeZone = 'UTC'` for digest construction | same semantic command executes first under UTC, then under Asia/Seoul; identity/digest plus the epoch-normalized original `recorded_at` instant must remain identical and bound to durable state | Implemented; hosted execution pending | +| Unsupported transaction isolation fails closed | `current_setting('transaction_isolation')` guard before command validation/write | `tests/test_document_record_idempotency_isolation_postgres.sh` invokes the owner inside a real `REPEATABLE READ` transaction and requires the explicit isolation error first | Implemented; hosted execution pending | +| Concurrent first attempts demonstrably serialize on the owner advisory lock | transaction-scoped `pg_advisory_xact_lock` over tenant + owner namespace + key | the first real PostgreSQL session remains `idle in transaction` after persistence; the second must expose an ungranted `advisory` lock in `pg_locks` and `pg_blocking_pids(...)` must name the first backend before the test permits the first transaction to commit | Implemented acceptance; hosted execution pending | +| Replay visibility is explicit | `VOLATILE` function + Read Committed guard | ADR 0309 ties post-lock receipt visibility to PostgreSQL statement snapshots and rejects stronger isolation until a successor algorithm exists | Implemented contract | +| No long external operation is inside the lock | ADR 0309 + database-only function body | source inspection: function performs digesting, replay lookup, local inserts, and receipt derivation only | Implemented; service adapter not yet present | +| Receipt cannot bind to another tenant's document | tenant-qualified UNIQUE on `document_record`; composite FK from receipt | migration DDL plus PostgreSQL acceptance | Implemented; hosted execution pending | +| Receipt RLS is behavioral, not metadata-only | FORCE RLS policy on `document_record_persist_receipt` | a temporary `NOBYPASSRLS`/non-superuser role granted only receipt SELECT/UPDATE can read its own tenant's receipts, sees zero rows under another tenant context, and cannot update hidden cross-tenant rows | Implemented acceptance; hosted execution pending | +| RLS acceptance cannot leak its probe principal on assertion failure | run-unique probe role plus shared `cleanup_probe_role(...)` and the test's existing EXIT trap | failure/abort cleanup is best-effort so it preserves the original assertion failure; the normal success path invokes the same cleanup strictly and fails if role discovery or `DROP OWNED`/`DROP ROLE` fails | Implemented after current-head review finding; exact-head re-review and hosted execution pending | +| Receipt state is append-only | append-only row trigger + TRUNCATE trigger | PostgreSQL contract requires same-tenant UPDATE rejection in addition to cross-tenant RLS invisibility | Implemented; hosted execution pending | +| Replay state is PII-minimized | receipt stores tenant, opaque key, digests, document identity, database time only | schema inspection; no document bytes, free-form HR values, credentials, compensation, rating, or duplicated Person/Employment columns | Implemented | +| Lost-response retry can recover authoritative identity and time | receipt persists in the same transaction as the document write | `tests/document_record_idempotency_postcommit_recovery_companion.sh` runs after the main idempotency root has established migration state, submits one simple-query batch that commits the owner write and then blocks in `pg_sleep`; a supervisory connection waits until the receipt is externally visible, captures the exact backend `(pid, application_name, backend_start)` identity, terminates only that still-matching committed backend, requires the original client to fail, and retries the exact semantic command from a new connection. The retry must equal the durable document/receipt identity, digests, references, and original database-owned time with one document + one receipt only | Implemented executable companion; hosted execution pending Foundation admission | +| Recovery termination cannot target a reused or foreign backend | per-execution UUID-derived `application_name` plus captured `pid` and `backend_start` | `tests/test_document_record_idempotency_postcommit_recovery_contract.py` requires the companion to capture `backend_start`, use an execution-unique session marker, and accept termination only when the same `(pid, application_name, backend_start)` still exists and `pg_terminate_backend` yields exact `1|true`; EXIT cleanup uses the same guarded helper | Implemented source contract; PostgreSQL execution pending Foundation admission | +| Acceptance connections are closed | test sessions use execution-scoped `PGAPPNAME` values and are waited before inspection | both the concurrent root contract and post-commit recovery companion require `pg_stat_activity` to contain zero matching sessions after acceptance | Implemented; hosted execution pending | +| Foundation cannot silently omit the new PostgreSQL contracts | #310/#311 owner-neutral discovery | #310/#311 must discover all four document-record idempotency PostgreSQL roots and execute the post-commit recovery script as an explicit reviewed companion of the main idempotency root; no feature-local workflow or filename-specific switch is added | Dependency pending stack reconciliation | +| Creation/retry receipt is not destruction-completion evidence | ADR 0309 / #308 boundary | #307 dependency order keeps #309/#312 and #308 as distinct prerequisites | Explicitly separated | + +## Evidence lineage + +- Parent authority: #107 `7ce73aa44f47113b2ecd42d51bb5d38a22c0367d`. +- Initial RED contract: `6260960f2909d34803dad2890f0c0bfd0f7bede7`. +- Initial owner migration/function: `b341784aaabca61dff2663986d24fd5e61b5a1c9`. +- Initial ADR: `3ef61434b04c6cc01d15788a62e71fc8036ad926`. +- First traceability commit: `c3ee1faaa30451e4d31fd959cf4abb77c3bd6a07`. +- PostgreSQL 16 / Read Committed ADR correction: `a2f4490423b97b21b8f94262157f2270cd53226e`. +- Timezone-drift RED: `db8360801ac852f343cebae5fdd592866c091ae7`. +- Function-local UTC causal fix: `00ba4ee03df7ca86bfc3ef2383e04de211532296`. +- Unsupported-isolation RED: `7a5393c279d9ef65f412a01ab891e71e4585c7fd`. +- Read Committed fail-closed causal fix: `bfc26948096e72524c434d22a7f6944e8446334b`. +- ADR currentization for owner-side isolation enforcement: `20dc8c8374c46d445de4ab19b67d3cbd95f527b9`. +- Cross-tenant retry RED contract: `cb7076c49fef13262fcb5c7f300cf902ce6715c9`. +- Pre-lock tenant-context causal fix: `147973ef2709dcaffefffa9f40c00f1a49d464d1`. +- Existing idempotency acceptance repaired to provide tenant context: `4ba85c535626f81468c74f4a5584385b1ad1a883`. +- ADR currentization for tenant-bound retry coordination: `db226ed24c00e692340922afb9150721581fe2ab`. +- Deterministic concurrency/RLS acceptance repair after review finding: `262122bfe0f959d5225e57bf8de9f9e54018af13`. +- Failure-path RLS probe-role cleanup repair: `4757e24ae03db08c10d93ccf07f6ddbf9fe73a85`. +- Success-path cleanup made fail-closed rather than best-effort: `be064e24280375c7eecb4b9e910f65d1145d434b`. +- Collision-resistant UUID-derived probe-principal identity: `fb8da85bc62df519828c480a3f07b8a44be344ee`. +- Replay-result time-shape acceptance: `3b9938902327ab819dc876544252ff9036993fb4`; result comparison includes epoch-normalized `recorded_at` and durable document/receipt times must match. +- Direct returned-time-to-durable-state binding: `e55e8076e595f6ae5afcd083bdca58c150d3fa6c`; the returned epoch must equal both durable document and receipt epochs, closing the review-identified false-GREEN path. +- Initial post-commit connection-loss recovery companion: `3157214ef1dd3b7767b7cc5a918e0b9b06cb7996`; it introduced the real backend-termination/retry scenario. +- Companion execution-order repair: `cb587679768eeb62712ca529f30d7a630817d5bd`; the companion no longer reapplies migrations and instead fails closed unless the main idempotency root has already established the owner schema/function, matching Foundation root→companion semantics. +- Backend-identity RED contract: `626c62e53dfb9a4a92ad711d19699c6cb922a2ba`; the prior companion terminated by captured PID alone and used one fixed application marker, so checked backend identity was not bound through use. +- Backend-identity causal repair: `8a485d60514e1a69124294c6656ffae0bb5d4f98`; recovery sessions now use UUID-derived markers, capture `backend_start`, and terminate/cleanup only an exact still-live `(pid, application_name, backend_start)` match with a `1|true` receipt. +- Persistence-function ambient-ACL RED contract: `9534af759331006b3dea2314d87903be2a718a30`; under PostgreSQL's documented defaults a fresh function is executable through `PUBLIC`, so a generic probe role would still have the capability. +- Ambient-ACL causal fix: `18327beef7d1ca9a92479c8e8be8fe1ce16e3721`; migration 0024 revokes `EXECUTE` from `PUBLIC` inside the same transaction that creates `persist_document_record_once(...)`. +- Execute-only capability RED: `12b474b5fd4218d6d9d94e5c2869b9c503035b71`; review of the callable boundary showed that a future SECURITY INVOKER service principal would need direct table privileges and could bypass the replay contract. +- Execute-only capability causal fix: `50c0259a24089cba7e848ab97887cbf0f581746e`; migration 0024 now uses fresh NOLOGIN/NOBYPASSRLS owner/executor roles, a restricted SECURITY DEFINER owner, temporary schema-CREATE handoff, and an executor with no table DML. + +## Evidence limits + +No hosted PostgreSQL execution is claimed on the current stacked branch. #312 targets #107, while the canonical PostgreSQL Foundation implementation is separately stacked under #259/#311. Exact-head GREEN requires ordinary-forward reconciliation of those histories and a fresh run that discovers all four roots plus the reviewed post-commit recovery companion without filename-specific workflow logic. + +The bounded observation loops in the concurrency and post-commit-recovery acceptance only wait for explicit PostgreSQL state. They do not use elapsed time as evidence that serialization or commit happened: concurrency fails unless the second backend is actually shown waiting on the first backend's advisory lock; post-commit recovery fails unless another session can read the durable receipt while the original backend is still alive in `PgSleep` before that exact captured backend identity is terminated. + +CodeRabbit/Devin status is review evidence only. It is not a substitute for the PostgreSQL runtime contracts, required protected-branch gates, or a qualifying independent approval. diff --git a/manifest.json b/manifest.json index acc10b373..cc4fafb9e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,529 +1 @@ -{ - "package": "orgmetra-foundation-pack", - "version": "0.1.0", - "generated_for_branch": "feat/audit-outbox-envelope", - "files": [ - { - "path": ".github/workflows/foundation-ci.yml", - "sha256": "b6a4365936b66803a8112f034c77d53d33301a7a798ed4f68746a4f2d8b081d7", - "bytes": 6651, - "lines": 125 - }, - { - "path": ".gitignore", - "sha256": "145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21", - "bytes": 375, - "lines": 37 - }, - { - "path": "AGENTS.md", - "sha256": "28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16", - "bytes": 2246, - "lines": 34 - }, - { - "path": "ARCHITECTURE.md", - "sha256": "52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850", - "bytes": 7864, - "lines": 107 - }, - { - "path": "CHANGELOG.md", - "sha256": "321c43f388dd561b8867684daac74b0c21f3676d66e15f941feed28d5cf02459", - "bytes": 17829, - "lines": 78 - }, - { - "path": "CLAUDE.md", - "sha256": "add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f", - "bytes": 1229, - "lines": 20 - }, - { - "path": "LICENSE", - "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", - "bytes": 11358, - "lines": 202 - }, - { - "path": "NOTICE", - "sha256": "34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042", - "bytes": 305, - "lines": 4 - }, - { - "path": "README.md", - "sha256": "1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6", - "bytes": 3785, - "lines": 81 - }, - { - "path": "database/migrations/0001_foundation_schema.sql", - "sha256": "ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd", - "bytes": 38747, - "lines": 916 - }, - { - "path": "database/migrations/0002_sealed_evidence_digest.sql", - "sha256": "93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c", - "bytes": 6649, - "lines": 202 - }, - { - "path": "database/migrations/0003_audit_outbox_persistence.sql", - "sha256": "2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc", - "bytes": 15417, - "lines": 423 - }, - { - "path": "database/migrations/0004_outbox_delivery_claim.sql", - "sha256": "d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef", - "bytes": 9451, - "lines": 234 - }, - { - "path": "database/migrations/0005_outbox_delivery_finalization.sql", - "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", - "bytes": 6125, - "lines": 170 - }, - { - "path": "database/migrations/0006_outbox_delivery_dead_letter.sql", - "sha256": "c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7", - "bytes": 24919, - "lines": 628 - }, - { - "path": "database/migrations/0007_outbox_retry_exhaustion.sql", - "sha256": "812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5", - "bytes": 19081, - "lines": 476 - }, - { - "path": "database/migrations/0008_audit_outbox_review_hardening.sql", - "sha256": "c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b", - "bytes": 17562, - "lines": 448 - }, - { - "path": "database/migrations/0009_candidate_worker_conversion_governance.sql", - "sha256": "4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9", - "bytes": 11537, - "lines": 281 - }, - { - "path": "database/migrations/0010_validity_study_case_integrity.sql", - "sha256": "3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1", - "bytes": 11979, - "lines": 313 - }, - { - "path": "database/migrations/0011_criterion_observation_scope.sql", - "sha256": "f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9", - "bytes": 7444, - "lines": 165 - }, - { - "path": "database/migrations/0012_people_mutation_idempotency.sql", - "sha256": "52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69", - "bytes": 3162, - "lines": 76 - }, - { - "path": "database/migrations/0013_job_analysis_snapshot.sql", - "sha256": "b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee", - "bytes": 12713, - "lines": 260 - }, - { - "path": "database/migrations/0021_document_record_persistence.sql", - "sha256": "e2dd9ca0c17141c2b3e06f64726f3ac0fa7cb1cd02798564cef72a12455a1286", - "bytes": 14001, - "lines": 334 - }, - { - "path": "database/migrations/0022_document_record_evidence_unique_keys.sql", - "sha256": "716960d224f1d2feabebc9302db93d7c2dd945978bbc806663eacc27f8606d73", - "bytes": 631, - "lines": 15 - }, - { - "path": "database/migrations/0023_document_record_canonical_encoding.sql", - "sha256": "9600bd071c39d904c041cf8037add92e582af577b2edc0de0f39801095dca195", - "bytes": 4364, - "lines": 87 - }, - { - "path": "docs/API_CONTRACT.md", - "sha256": "63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589", - "bytes": 4555, - "lines": 76 - }, - { - "path": "docs/DATA_MODEL.md", - "sha256": "6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a", - "bytes": 13366, - "lines": 85 - }, - { - "path": "docs/ERD.md", - "sha256": "546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe", - "bytes": 6964, - "lines": 70 - }, - { - "path": "docs/OPERABILITY.md", - "sha256": "82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62", - "bytes": 11189, - "lines": 71 - }, - { - "path": "docs/PRD.md", - "sha256": "3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1", - "bytes": 5490, - "lines": 111 - }, - { - "path": "docs/SECURITY.md", - "sha256": "01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac", - "bytes": 11185, - "lines": 64 - }, - { - "path": "docs/STORYBOARD.md", - "sha256": "6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2", - "bytes": 1342, - "lines": 28 - }, - { - "path": "docs/STORYBOOK.md", - "sha256": "82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9", - "bytes": 1389, - "lines": 50 - }, - { - "path": "docs/TEST_STRATEGY.md", - "sha256": "d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8", - "bytes": 16534, - "lines": 135 - }, - { - "path": "docs/THREAT_MODEL.md", - "sha256": "f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252", - "bytes": 6736, - "lines": 23 - }, - { - "path": "docs/TRACEABILITY.md", - "sha256": "dbf6fd91375ea28e05456d2a0c9ba629506cbac6f52f5dfda61ae68db2395f7e", - "bytes": 11462, - "lines": 40 - }, - { - "path": "docs/TRD.md", - "sha256": "23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077", - "bytes": 9064, - "lines": 101 - }, - { - "path": "docs/UML.md", - "sha256": "fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9", - "bytes": 5528, - "lines": 122 - }, - { - "path": "docs/USER_STORIES.md", - "sha256": "5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f", - "bytes": 2670, - "lines": 37 - }, - { - "path": "docs/WIREFRAMES.md", - "sha256": "b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e", - "bytes": 2005, - "lines": 77 - }, - { - "path": "docs/adr/0001-orgmetra-authoritative-hris-record.md", - "sha256": "0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572", - "bytes": 6108, - "lines": 53 - }, - { - "path": "docs/adr/0002-federated-cwl-integration-boundaries.md", - "sha256": "b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2", - "bytes": 4072, - "lines": 44 - }, - { - "path": "docs/adr/0003-bitemporal-hris-data-contract.md", - "sha256": "d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799", - "bytes": 4453, - "lines": 47 - }, - { - "path": "docs/adr/0004-employment-position-version-and-assignment-binding.md", - "sha256": "fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182", - "bytes": 1872, - "lines": 30 - }, - { - "path": "docs/adr/0005-exclusive-employment-and-staffable-seats.md", - "sha256": "10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b", - "bytes": 2091, - "lines": 34 - }, - { - "path": "docs/adr/0006-governed-audit-outbox-envelope.md", - "sha256": "827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd", - "bytes": 14100, - "lines": 66 - }, - { - "path": "docs/adr/0007-governed-job-analysis-evidence.md", - "sha256": "953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52", - "bytes": 5653, - "lines": 57 - }, - { - "path": "docs/adr/0008-purpose-bound-pii-authorization.md", - "sha256": "c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7", - "bytes": 5988, - "lines": 55 - }, - { - "path": "docs/adr/0009-performance-criterion-observation-scope.md", - "sha256": "1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64", - "bytes": 7057, - "lines": 57 - }, - { - "path": "docs/adr/0010-naruon-calendar-intent-boundary.md", - "sha256": "3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9", - "bytes": 3917, - "lines": 35 - }, - { - "path": "docs/adr/0011-bitemporal-workforce-composition.md", - "sha256": "dbe96dfd47066288cec835789de54cc4293f920d2ad4b0e0dba930191d7d249b", - "bytes": 5551, - "lines": 53 - }, - { - "path": "docs/adr/0012-governed-migration-handoff.md", - "sha256": "c7bfbda34996f717ed31f8307acc16a5d69ae464edb184ab5c8ec4b2d5763cbc", - "bytes": 5958, - "lines": 59 - }, - { - "path": "docs/adr/0013-governed-requisition-review-packet.md", - "sha256": "70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802", - "bytes": 4693, - "lines": 46 - }, - { - "path": "docs/adr/0014-job-analysis-snapshot-persistence.md", - "sha256": "a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105", - "bytes": 5365, - "lines": 49 - }, - { - "path": "docs/adr/0107-document-record-persistence.md", - "sha256": "04dcfa124d8cfcb2dc1527fbafaf82a217a0471f5aa27392e52ce5dfcea54810", - "bytes": 6448, - "lines": 48 - }, - { - "path": "docs/adr/README.md", - "sha256": "f3b3b5ed3b3b31a40a0a3696abf0065e3c25879b6be50077f38ffae742b9d002", - "bytes": 1838, - "lines": 18 - }, - { - "path": "docs/doctoring/REFERENCES.md", - "sha256": "929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5", - "bytes": 6352, - "lines": 69 - }, - { - "path": "docs/doctoring/document-record-persistence-references.md", - "sha256": "e371afc5b15351682b66477e48654554704a62fa9db5fd134767d47ce8d88b36", - "bytes": 2038, - "lines": 23 - }, - { - "path": "docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md", - "sha256": "b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd", - "bytes": 8227, - "lines": 226 - }, - { - "path": "docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md", - "sha256": "4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d", - "bytes": 6237, - "lines": 187 - }, - { - "path": "docs/traceability/document-record-persistence.md", - "sha256": "3849ab6642f8b171aae6b379781fb19028635996428fbc37788334bd1feb167a", - "bytes": 4120, - "lines": 30 - }, - { - "path": "package.json", - "sha256": "59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5", - "bytes": 388, - "lines": 9 - }, - { - "path": "packages/hris-kernel/src/orgmetra_hris_kernel/audit.py", - "sha256": "3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190", - "bytes": 7707, - "lines": 160 - }, - { - "path": "packages/hris-kernel/tests/test_audit_outbox.py", - "sha256": "5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c", - "bytes": 7556, - "lines": 200 - }, - { - "path": "schemas/openapi.yaml", - "sha256": "09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f", - "bytes": 29503, - "lines": 1020 - }, - { - "path": "scripts/foundation-contract-core.mjs", - "sha256": "2305e1a6efff8b0dc47b40d517ac49b2a86f780899ae03c83d4063ed30d169e5", - "bytes": 28670, - "lines": 697 - }, - { - "path": "scripts/foundation-contract.mjs", - "sha256": "5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a", - "bytes": 218, - "lines": 6 - }, - { - "path": "tests/dispatcher-inventory.test.mjs", - "sha256": "09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261", - "bytes": 1597, - "lines": 34 - }, - { - "path": "tests/foundation-contract.test.mjs", - "sha256": "648533b4aff8cee643df4afc06b463eda788e002e11d043971c8a16804c68501", - "bytes": 14943, - "lines": 387 - }, - { - "path": "tests/openapi-contract.test.mjs", - "sha256": "80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc", - "bytes": 6438, - "lines": 195 - }, - { - "path": "tests/test_audit_outbox_hardening_postgres.sh", - "sha256": "518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0", - "bytes": 13396, - "lines": 333 - }, - { - "path": "tests/test_audit_outbox_postgres.sh", - "sha256": "e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2", - "bytes": 13443, - "lines": 357 - }, - { - "path": "tests/test_bitemporal_postgres.sh", - "sha256": "7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc", - "bytes": 8209, - "lines": 230 - }, - { - "path": "tests/test_candidate_worker_conversion_postgres.sh", - "sha256": "681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90", - "bytes": 14673, - "lines": 344 - }, - { - "path": "tests/test_criterion_observation_scope_postgres.sh", - "sha256": "0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d", - "bytes": 17811, - "lines": 469 - }, - { - "path": "tests/test_document_record_canonical_bytes_postgres.sh", - "sha256": "10b12f2f7afeb04fc04182170d2d00bca69b4f0f51c604ee565308b906d8c550", - "bytes": 8394, - "lines": 177 - }, - { - "path": "tests/test_document_record_evidence_unique_keys_postgres.sh", - "sha256": "d678b4ee7f1c69c16632f188ae8f7b5c717ecc55e49dc09d7889ebe02ed7212a", - "bytes": 5058, - "lines": 103 - }, - { - "path": "tests/test_document_record_persistence_postgres.sh", - "sha256": "f3d5fcb83a406ac202986f0272f673a8f3c6349752119d27b4d42e4c6bbe7072", - "bytes": 17015, - "lines": 376 - }, - { - "path": "tests/test_evidence_sealing_postgres.sh", - "sha256": "57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7", - "bytes": 11349, - "lines": 370 - }, - { - "path": "tests/test_job_analysis_snapshot_postgres.sh", - "sha256": "ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f", - "bytes": 13542, - "lines": 296 - }, - { - "path": "tests/test_operational_uuid_postgres.sh", - "sha256": "7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7", - "bytes": 3346, - "lines": 101 - }, - { - "path": "tests/test_outbox_claim_postgres.sh", - "sha256": "1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b", - "bytes": 14817, - "lines": 429 - }, - { - "path": "tests/test_outbox_dead_letter_postgres.sh", - "sha256": "0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d", - "bytes": 14008, - "lines": 377 - }, - { - "path": "tests/test_people_mutation_idempotency_postgres.sh", - "sha256": "3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5", - "bytes": 16191, - "lines": 381 - }, - { - "path": "tests/test_tenant_isolation_postgres.sh", - "sha256": "dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a", - "bytes": 15134, - "lines": 388 - }, - { - "path": "tests/test_validity_study_case_postgres.sh", - "sha256": "0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02", - "bytes": 14708, - "lines": 301 - }, - { - "path": "tests/validate_repository.py", - "sha256": "0692bfa17ebed3ba6594bc30e75bfeb8e93784ae381af3801457f4fe6cf41529", - "bytes": 27804, - "lines": 646 - } - ] -} +{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"b6a4365936b66803a8112f034c77d53d33301a7a798ed4f68746a4f2d8b081d7","bytes":6651,"lines":125},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"321c43f388dd561b8867684daac74b0c21f3676d66e15f941feed28d5cf02459","bytes":17829,"lines":78},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"database/migrations/0021_document_record_persistence.sql","sha256":"e2dd9ca0c17141c2b3e06f64726f3ac0fa7cb1cd02798564cef72a12455a1286","bytes":14001,"lines":334},{"path":"database/migrations/0022_document_record_evidence_unique_keys.sql","sha256":"716960d224f1d2feabebc9302db93d7c2dd945978bbc806663eacc27f8606d73","bytes":631,"lines":15},{"path":"database/migrations/0023_document_record_canonical_encoding.sql","sha256":"9600bd071c39d904c041cf8037add92e582af577b2edc0de0f39801095dca195","bytes":4364,"lines":87},{"path":"database/migrations/0024_document_record_idempotent_persistence.sql","sha256":"31b25665cd2a4be256d0fa3e392fdbfc7e7f87c51721dfa4f5d0c6bec2dbbbaa","bytes":17124,"lines":394},{"path":"docs/API_CONTRACT.md","sha256":"63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589","bytes":4555,"lines":76},{"path":"docs/DATA_MODEL.md","sha256":"0528c309dd9ce998f71d91a474f6106ee49f0328fb9c9697b81f8ebe5e9439f5","bytes":16169,"lines":97},{"path":"docs/ERD.md","sha256":"2fef217f1b7789685dcd7cc37fef25782c2bb48268b5e39001293a9cbb52f755","bytes":8217,"lines":75},{"path":"docs/OPERABILITY.md","sha256":"8db14e56a3c358b8ce97532862d70a440d9141459e6e4e81fa587a998d684d22","bytes":15891,"lines":87},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"73e052e80ec3e8b4669d31dd1699eea54cc5325cddf0db16c90ba5d8e247a93a","bytes":11980,"lines":65},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"8b24cc0af5b5f5040cfd0052ec9f27452e9afb47969d5b717a81b5bcac466fe3","bytes":20040,"lines":140},{"path":"docs/THREAT_MODEL.md","sha256":"95a2367829820a1c7732e292adb89a38be55452f60e7bf3c6caa347450de6ec0","bytes":8595,"lines":25},{"path":"docs/TRACEABILITY.md","sha256":"dbf6fd91375ea28e05456d2a0c9ba629506cbac6f52f5dfda61ae68db2395f7e","bytes":11462,"lines":40},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9","bytes":5528,"lines":122},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"dbe96dfd47066288cec835789de54cc4293f920d2ad4b0e0dba930191d7d249b","bytes":5551,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"c7bfbda34996f717ed31f8307acc16a5d69ae464edb184ab5c8ec4b2d5763cbc","bytes":5958,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/0107-document-record-persistence.md","sha256":"04dcfa124d8cfcb2dc1527fbafaf82a217a0471f5aa27392e52ce5dfcea54810","bytes":6448,"lines":48},{"path":"docs/adr/0309-document-record-idempotent-persistence.md","sha256":"9e0bf26886b246956b2bcb540ff5d9e4e0d5e480d578c0f6c6d0951cb0415ec0","bytes":16322,"lines":104},{"path":"docs/adr/README.md","sha256":"f3b3b5ed3b3b31a40a0a3696abf0065e3c25879b6be50077f38ffae742b9d002","bytes":1838,"lines":18},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/doctoring/document-record-persistence-references.md","sha256":"e371afc5b15351682b66477e48654554704a62fa9db5fd134767d47ce8d88b36","bytes":2038,"lines":23},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"docs/traceability/document-record-idempotent-persistence.md","sha256":"c585a24a744a41ca75da0f9e2e14ad354841bd24bad9e06660005bd4564767bf","bytes":13411,"lines":66},{"path":"docs/traceability/document-record-persistence.md","sha256":"3849ab6642f8b171aae6b379781fb19028635996428fbc37788334bd1feb167a","bytes":4120,"lines":30},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f","bytes":29503,"lines":1020},{"path":"scripts/foundation-contract-core.mjs","sha256":"7ca3b9d37d9fe168ece9866d5af8ffbc01dd8c1f91a96a42d26b89c909ffce46","bytes":29351,"lines":707},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/document_record_idempotency_postcommit_recovery_companion.sh","sha256":"ecbde2131fc5341dc11017305d0bb114136dd1289a05ece14e7c3080a8a52e93","bytes":11193,"lines":277},{"path":"tests/foundation-contract.test.mjs","sha256":"ecffcf694c2388b55d665bb94e1112dcdaf4cc3266cc4e3f928a1cae60cb944b","bytes":16343,"lines":420},{"path":"tests/openapi-contract.test.mjs","sha256":"80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc","bytes":6438,"lines":195},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_document_record_canonical_bytes_postgres.sh","sha256":"10b12f2f7afeb04fc04182170d2d00bca69b4f0f51c604ee565308b906d8c550","bytes":8394,"lines":177},{"path":"tests/test_document_record_evidence_unique_keys_postgres.sh","sha256":"d678b4ee7f1c69c16632f188ae8f7b5c717ecc55e49dc09d7889ebe02ed7212a","bytes":5058,"lines":103},{"path":"tests/test_document_record_idempotency_function_acl_postgres.sh","sha256":"81b0587ea27101bc57553de9c7da09e8cccb93807dff1128d17df263085e9714","bytes":10268,"lines":247},{"path":"tests/test_document_record_idempotency_isolation_postgres.sh","sha256":"a8e7a099e9b046753591167ddfe4e4206bccd2f00f182925720fa3c15b880a32","bytes":1873,"lines":62},{"path":"tests/test_document_record_idempotency_postcommit_recovery_contract.py","sha256":"69cea86ef064c7bc8135d1e28f8dfd6eb893fdd6ee8b314045492971adc95aa1","bytes":2318,"lines":68},{"path":"tests/test_document_record_idempotency_postgres.sh","sha256":"db078f2d5876e844d455915d3446dc8c25ca8ba95cac87481c26911f839e246d","bytes":17324,"lines":413},{"path":"tests/test_document_record_idempotency_tenant_context_postgres.sh","sha256":"171f35d16891ac58d67a54504ce051a4bff0fedbf7ab43bed2f9ef3f3765b945","bytes":5830,"lines":140},{"path":"tests/test_document_record_persistence_postgres.sh","sha256":"f3d5fcb83a406ac202986f0272f673a8f3c6349752119d27b4d42e4c6bbe7072","bytes":17015,"lines":376},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"6c39b7e25ed127b34a74532ae5e607943b1f73b964bc931f256dc99df75f6b54","bytes":28431,"lines":655}]} diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index ac190292d..da71ee93f 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -52,9 +52,11 @@ export const REQUIRED_FILES = Object.freeze([ 'docs/adr/0013-governed-requisition-review-packet.md', 'docs/adr/0014-job-analysis-snapshot-persistence.md', 'docs/adr/0107-document-record-persistence.md', + 'docs/adr/0309-document-record-idempotent-persistence.md', 'docs/doctoring/REFERENCES.md', 'docs/doctoring/document-record-persistence-references.md', 'docs/traceability/document-record-persistence.md', + 'docs/traceability/document-record-idempotent-persistence.md', 'docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md', 'docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md', 'database/migrations/0001_foundation_schema.sql', @@ -73,6 +75,7 @@ export const REQUIRED_FILES = Object.freeze([ 'database/migrations/0021_document_record_persistence.sql', 'database/migrations/0022_document_record_evidence_unique_keys.sql', 'database/migrations/0023_document_record_canonical_encoding.sql', + 'database/migrations/0024_document_record_idempotent_persistence.sql', 'packages/hris-kernel/src/orgmetra_hris_kernel/audit.py', 'packages/hris-kernel/tests/test_audit_outbox.py', 'schemas/openapi.yaml', @@ -97,6 +100,12 @@ export const REQUIRED_FILES = Object.freeze([ 'tests/test_document_record_canonical_bytes_postgres.sh', 'tests/test_document_record_evidence_unique_keys_postgres.sh', 'tests/test_document_record_persistence_postgres.sh', + 'tests/test_document_record_idempotency_postgres.sh', + 'tests/test_document_record_idempotency_function_acl_postgres.sh', + 'tests/test_document_record_idempotency_isolation_postgres.sh', + 'tests/test_document_record_idempotency_tenant_context_postgres.sh', + 'tests/document_record_idempotency_postcommit_recovery_companion.sh', + 'tests/test_document_record_idempotency_postcommit_recovery_contract.py', 'tests/validate_repository.py' ]); @@ -133,7 +142,7 @@ export const DATABASE_OBJECT_NAMES = Object.freeze([ 'compensation_decision', 'validation_study', 'study_population_snapshot', 'study_predictor_link', 'study_criterion_link', 'analysis_manifest', 'analysis_artifact', 'policy_recommendation', 'policy_review_decision', - 'document_record', 'document_version', 'document_segment', 'image_artifact', + 'document_record', 'document_record_persist_receipt', 'document_version', 'document_segment', 'image_artifact', 'evidence_record', 'evidence_source_segment', 'authorization_policy', 'authorization_decision', 'audit_event', 'audit_event_record', 'data_rights_request', 'outbox_event', 'outbox_delivery_record', 'outbox_delivery_escalation_record', @@ -147,7 +156,8 @@ export const MIGRATION_BACKED_DATABASE_OBJECT_NAMES = Object.freeze([ 'job_analysis_task_item', 'job_analysis_ksao_item', 'job_analysis_task_ksao_link', - 'job_analysis_write_command' + 'job_analysis_write_command', + 'document_record_persist_receipt' ]); const UNFINISHED_MARKER_LINE_PATTERN = /^\s*(?:#{1,6}\s+|[-*+]\s+)?(?:\[(?:TODO|TBD|FIXME)\]|\{\{(?:TODO|TBD|FIXME)\}\}|<(?:TODO|TBD|FIXME)>|(?:TODO|TBD|FIXME)(?:\s*:\s*.*)?\s*)$/i; diff --git a/tests/document_record_idempotency_postcommit_recovery_companion.sh b/tests/document_record_idempotency_postcommit_recovery_companion.sh new file mode 100644 index 000000000..f991e5846 --- /dev/null +++ b/tests/document_record_idempotency_postcommit_recovery_companion.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +required_owner_state="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 -c " +SELECT ( + pg_catalog.to_regclass('public.document_record_persist_receipt') IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc + WHERE pronamespace = 'public'::pg_catalog.regnamespace + AND proname = 'persist_document_record_once' + ) +)::text; +")" +if [[ "${required_owner_state}" != "true" ]]; then + echo "post-commit recovery companion requires the document-record idempotency root contract to run first" >&2 + exit 1 +fi + +TENANT_ID="30000000-0000-7000-8000-000000000003" +DOCUMENT_ID="00000000-0000-7000-8000-000000000231" +DOCUMENT_REFERENCE="document_record:00000000-0000-4000-8000-000000000231" +PERSON_REFERENCE="person_record:00000000-0000-4000-8000-000000000211" +EMPLOYMENT_REFERENCE="employment_record:00000000-0000-4000-8000-000000000221" +UPLOADER="actor:00000000-0000-4000-8000-000000000261" +PERSISTED_BY="actor:00000000-0000-4000-8000-000000000262" +ARTIFACT_REFERENCE="document_artifact:00000000-0000-4000-8000-000000000241" +ARTIFACT_DIGEST="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +SOURCE_DIGEST="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +RETENTION_REFERENCE="retention_policy:00000000-0000-4000-8000-000000000251" +RETENTION_DIGEST="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +APPLICATION_DIGEST="eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +AUDIT_REFERENCE="audit_event:00000000-0000-4000-8000-000000000271" +OUTBOX_REFERENCE="outbox_event:00000000-0000-4000-8000-000000000272" +IDEMPOTENCY_KEY="document-record-persist-00000000-0000-4000-8000-000000000201" +APPLICATION_SUFFIX="$(python3 - <<'PY' +import uuid + +print(uuid.uuid4().hex[:24]) +PY +)" +APPLICATION_NAME="orgmetra_document_idempotency_lost_${APPLICATION_SUFFIX}" + +IFS='|' read -r RECEIVED_AT EVIDENCE_RECORDED_AT < <(psql "${DATABASE_URL}" -Atqc " +SELECT + to_char((pg_catalog.transaction_timestamp() - interval '2 minutes') AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'), + to_char((pg_catalog.transaction_timestamp() - interval '1 minute') AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'); +") +export RECEIVED_AT EVIDENCE_RECORDED_AT + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 </dev/null 2>&1 || true + fi + if [[ -n "${client_pid}" ]] && kill -0 "${client_pid}" 2>/dev/null; then + kill "${client_pid}" 2>/dev/null || true + wait "${client_pid}" 2>/dev/null || true + fi + rm -rf "${RECOVERY_DIR}" +} +trap cleanup EXIT + +# One simple-query batch commits the authoritative write and then remains inside +# pg_sleep. The supervising recovery actor never receives the function result. +# Terminating the backend only after another session can observe the receipt +# proves that the durable first result exists while the original connection +# ultimately reports failure to its caller. +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME="${APPLICATION_NAME}" \ +psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 \ + -v canonical_evidence="${CANONICAL_EVIDENCE}" \ + -c "BEGIN; ${PERSIST_SQL} COMMIT; SELECT pg_catalog.pg_sleep(30);" \ + >/dev/null 2>"${CLIENT_ERROR}" & +client_pid=$! + +first_result_durable=false +recovery_deadline=$((SECONDS + 10)) +while (( SECONDS < recovery_deadline )); do + activity_row="$(psql "${DATABASE_URL}" -Atqc " +SELECT + activity.pid::text || '|' || extract(epoch FROM activity.backend_start)::text || '|' || + activity.state || '|' || COALESCE(activity.wait_event, '') || '|' || + ( + SELECT count(*)::text + FROM public.document_record_persist_receipt AS receipt + WHERE receipt.tenant_record_id = '${TENANT_ID}'::uuid + AND receipt.idempotency_key = '${IDEMPOTENCY_KEY}' + ) +FROM pg_catalog.pg_stat_activity AS activity +WHERE activity.application_name = '${APPLICATION_NAME}'; +" | head -n 1)" + observed_pid="" + observed_backend_start_epoch="" + observed_state="" + observed_wait="" + receipt_count="" + if [[ -n "${activity_row}" ]]; then + IFS='|' read -r observed_pid observed_backend_start_epoch observed_state observed_wait receipt_count <<<"${activity_row}" + fi + if [[ "${observed_pid}" =~ ^[0-9]+$ && "${observed_backend_start_epoch}" =~ ^[0-9]+([.][0-9]+)?$ && "${observed_state}" == "active" && "${observed_wait}" == "PgSleep" && "${receipt_count}" == "1" ]]; then + backend_pid="${observed_pid}" + backend_start_epoch="${observed_backend_start_epoch}" + first_result_durable=true + break + fi + if ! kill -0 "${client_pid}" 2>/dev/null; then + break + fi + sleep 0.05 +done + +if [[ "${first_result_durable}" != "true" ]]; then + echo "post-commit recovery fixture never exposed a durable receipt while the original connection remained live" >&2 + cat "${CLIENT_ERROR}" >&2 || true + exit 1 +fi + +if ! terminate_captured_backend; then + echo "captured persistence backend identity changed or could not be terminated safely" >&2 + exit 1 +fi +backend_pid="" +backend_start_epoch="" + +set +e +wait "${client_pid}" +client_status=$? +set -e +client_pid="" +if [[ ${client_status} -eq 0 ]]; then + echo "original persistence client unexpectedly reported success after forced post-commit connection termination" >&2 + exit 1 +fi + +DURABLE_RESULT="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 -c " +SELECT + persisted.document_record_id::text || '|' || persisted.document_record_reference || '|' || + persisted.audit_event_reference || '|' || persisted.outbox_event_reference || '|' || + receipt.semantic_command_digest_sha256 || '|' || receipt.receipt_digest_sha256 || '|' || + extract(epoch FROM persisted.recorded_at)::text +FROM public.document_record_persist_receipt AS receipt +JOIN public.document_record AS persisted + ON persisted.tenant_record_id = receipt.tenant_record_id + AND persisted.document_record_id = receipt.document_record_id +WHERE receipt.tenant_record_id = '${TENANT_ID}'::uuid + AND receipt.idempotency_key = '${IDEMPOTENCY_KEY}'; +")" +if [[ -z "${DURABLE_RESULT}" ]]; then + echo "forced connection loss erased or hid the committed authoritative result" >&2 + exit 1 +fi + +RETRY_RESULT="$(PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ + psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 \ + -v canonical_evidence="${CANONICAL_EVIDENCE}" -c "${PERSIST_SQL}")" +if [[ "${RETRY_RESULT}" != "${DURABLE_RESULT}" ]]; then + echo "same-command retry did not recover the exact durable result after post-commit connection loss: durable=${DURABLE_RESULT} retry=${RETRY_RESULT}" >&2 + exit 1 +fi + +recovery_counts="$(psql "${DATABASE_URL}" -Atqc " +SELECT + (SELECT count(*) FROM public.document_record WHERE tenant_record_id = '${TENANT_ID}'::uuid AND document_record_id = '${DOCUMENT_ID}'::uuid)::text + || '|' || + (SELECT count(*) FROM public.document_record_persist_receipt WHERE tenant_record_id = '${TENANT_ID}'::uuid AND idempotency_key = '${IDEMPOTENCY_KEY}')::text; +")" +if [[ "${recovery_counts}" != "1|1" ]]; then + echo "post-commit retry duplicated durable document or receipt state: ${recovery_counts}" >&2 + exit 1 +fi + +active_recovery_connections="$(psql "${DATABASE_URL}" -Atqc " +SELECT count(*) +FROM pg_catalog.pg_stat_activity +WHERE application_name = '${APPLICATION_NAME}'; +")" +if [[ "${active_recovery_connections}" != "0" ]]; then + echo "post-commit recovery acceptance leaked PostgreSQL connections: ${active_recovery_connections}" >&2 + exit 1 +fi diff --git a/tests/foundation-contract.test.mjs b/tests/foundation-contract.test.mjs index c2f8f7589..139e62b19 100644 --- a/tests/foundation-contract.test.mjs +++ b/tests/foundation-contract.test.mjs @@ -53,6 +53,11 @@ function writeMigrationBackedTables(root) { 'CREATE TABLE job_analysis_write_command (tenant_record_id uuid NOT NULL);' ].join('\n') + '\n' ); + write( + root, + 'database/migrations/0024_document_record_idempotent_persistence.sql', + 'CREATE TABLE document_record_persist_receipt (tenant_record_id uuid NOT NULL);\n' + ); } function makeMinimalValidFoundation(root) { @@ -129,6 +134,8 @@ test('required constants are frozen and use accepted values', () => { assert.ok(REQUIRED_FILES.length > 20); assert.ok(DATABASE_OBJECT_NAMES.every(isValidDatabaseObjectName)); assert.ok(DATABASE_OBJECT_NAMES.includes('people_mutation_idempotency_record')); + assert.ok(DATABASE_OBJECT_NAMES.includes('document_record_persist_receipt')); + assert.ok(MIGRATION_BACKED_DATABASE_OBJECT_NAMES.includes('document_record_persist_receipt')); assert.ok(MATURITY_VALUES.has('accepted_architecture')); }); @@ -182,6 +189,32 @@ test('migration-backed validation ignores fake CREATE TABLE text in comments and } }); +test('document receipt migration identity requires executable CREATE TABLE evidence', () => { + const root = temporaryDirectory(); + try { + write( + root, + 'database/migrations/0012_people_mutation_idempotency.sql', + 'CREATE TABLE people_mutation_idempotency_record (tenant_record_id uuid NOT NULL);\n' + ); + writeMigrationBackedTables(root); + write( + root, + 'database/migrations/0024_document_record_idempotent_persistence.sql', + [ + '-- CREATE TABLE document_record_persist_receipt (tenant_record_id uuid);', + "SELECT 'CREATE TABLE document_record_persist_receipt (tenant_record_id uuid);';", + 'SELECT $receipt$CREATE TABLE document_record_persist_receipt (tenant_record_id uuid);$receipt$;' + ].join('\n') + ); + assert.deepEqual(validateMigrationBackedDatabaseObjectNames(root), [ + 'Migration-backed database object is missing from migrations: document_record_persist_receipt' + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('collectMarkdownFiles handles missing directories and stable recursion', () => { const root = temporaryDirectory(); try { diff --git a/tests/test_document_record_idempotency_function_acl_postgres.sh b/tests/test_document_record_idempotency_function_acl_postgres.sh new file mode 100644 index 000000000..c974a5218 --- /dev/null +++ b/tests/test_document_record_idempotency_function_acl_postgres.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0021_document_record_persistence.sql \ + database/migrations/0022_document_record_evidence_unique_keys.sql \ + database/migrations/0023_document_record_canonical_encoding.sql \ + database/migrations/0024_document_record_idempotent_persistence.sql; do + if [[ ! -f "${migration}" ]]; then + echo "required document-record idempotency migration is missing: ${migration}" >&2 + exit 1 + fi + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +FUNCTION_OWNER_ROLE="orgmetra_document_persistence_owner" +FUNCTION_EXECUTOR_ROLE="orgmetra_document_persistence_executor" +PROBE_ROLE_SUFFIX="$(python3 - <<'PY' +import uuid + +print(uuid.uuid4().hex[:24]) +PY +)" +PROBE_ROLE="orgmetra_document_persist_acl_probe_${PROBE_ROLE_SUFFIX}" +FUNCTION_SIGNATURE="public.persist_document_record_once(uuid,text,uuid,text,text,text,text,text,text,text,text,text,text,text,timestamptz,text,text,text,text,text)" + +cleanup_probe_role() { + local mode="${1:-strict}" + local role_exists + + if ! role_exists="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 -c \ + "SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${PROBE_ROLE}';" 2>/dev/null)"; then + if [[ "${mode}" == "best-effort" ]]; then + return 0 + fi + echo "could not verify temporary function-ACL probe-role cleanup" >&2 + return 1 + fi + if [[ "${role_exists}" != "1" ]]; then + return 0 + fi + if psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 >/dev/null 2>&1 <&2 + return 1 +} + +cleanup() { + cleanup_probe_role best-effort +} +trap cleanup EXIT + +capability_role_state="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 -c " +SELECT + owner_role.rolcanlogin::text || '|' || + owner_role.rolsuper::text || '|' || + owner_role.rolcreatedb::text || '|' || + owner_role.rolcreaterole::text || '|' || + owner_role.rolreplication::text || '|' || + owner_role.rolbypassrls::text || '|' || + executor_role.rolcanlogin::text || '|' || + executor_role.rolsuper::text || '|' || + executor_role.rolcreatedb::text || '|' || + executor_role.rolcreaterole::text || '|' || + executor_role.rolreplication::text || '|' || + executor_role.rolbypassrls::text +FROM pg_catalog.pg_roles AS owner_role +JOIN pg_catalog.pg_roles AS executor_role + ON executor_role.rolname = '${FUNCTION_EXECUTOR_ROLE}' +WHERE owner_role.rolname = '${FUNCTION_OWNER_ROLE}'; +")" +if [[ "${capability_role_state}" != "false|false|false|false|false|false|false|false|false|false|false|false" ]]; then + echo "document persistence capability roles are missing or not deny-default NOLOGIN/NOBYPASSRLS roles: ${capability_role_state}" >&2 + exit 1 +fi + +function_authority_state="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 -c " +SELECT + function_owner.rolname || '|' || + function_record.prosecdef::text || '|' || + pg_catalog.has_schema_privilege('${FUNCTION_OWNER_ROLE}', 'public', 'USAGE')::text || '|' || + pg_catalog.has_schema_privilege('${FUNCTION_OWNER_ROLE}', 'public', 'CREATE')::text || '|' || + pg_catalog.has_schema_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public', 'USAGE')::text || '|' || + pg_catalog.has_schema_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public', 'CREATE')::text || '|' || + pg_catalog.has_function_privilege('${FUNCTION_OWNER_ROLE}', '${FUNCTION_SIGNATURE}', 'EXECUTE')::text || '|' || + pg_catalog.has_function_privilege('${FUNCTION_EXECUTOR_ROLE}', '${FUNCTION_SIGNATURE}', 'EXECUTE')::text +FROM pg_catalog.pg_proc AS function_record +JOIN pg_catalog.pg_roles AS function_owner + ON function_owner.oid = function_record.proowner +WHERE function_record.oid = '${FUNCTION_SIGNATURE}'::pg_catalog.regprocedure; +")" +if [[ "${function_authority_state}" != "${FUNCTION_OWNER_ROLE}|true|true|false|true|false|true|true" ]]; then + echo "document persistence function is not a purpose-bound SECURITY DEFINER capability: ${function_authority_state}" >&2 + exit 1 +fi + +owner_table_state="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 -c " +SELECT + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record', 'SELECT')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record', 'INSERT')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record', 'UPDATE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record', 'DELETE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record', 'TRUNCATE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record_persist_receipt', 'SELECT')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record_persist_receipt', 'INSERT')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record_persist_receipt', 'UPDATE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record_persist_receipt', 'DELETE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_OWNER_ROLE}', 'public.document_record_persist_receipt', 'TRUNCATE')::text; +")" +if [[ "${owner_table_state}" != "true|true|false|false|false|true|true|false|false|false" ]]; then + echo "document persistence function owner has an unexpected direct-table privilege set: ${owner_table_state}" >&2 + exit 1 +fi + +executor_table_state="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 -c " +SELECT + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record', 'SELECT')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record', 'INSERT')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record', 'UPDATE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record', 'DELETE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record', 'TRUNCATE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record_persist_receipt', 'SELECT')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record_persist_receipt', 'INSERT')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record_persist_receipt', 'UPDATE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record_persist_receipt', 'DELETE')::text || '|' || + pg_catalog.has_table_privilege('${FUNCTION_EXECUTOR_ROLE}', 'public.document_record_persist_receipt', 'TRUNCATE')::text; +")" +if [[ "${executor_table_state}" != "false|false|false|false|false|false|false|false|false|false" ]]; then + echo "document persistence executor can bypass the function through direct table privileges: ${executor_table_state}" >&2 + exit 1 +fi + +set +e +executor_direct_table_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 2>&1 <&2 + exit 1 +fi + +set +e +executor_function_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 2>&1 <&2 + exit 1 +fi + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <&2 + exit 1 +fi + +set +e +probe_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 2>&1 <&2 + exit 1 +fi + +cleanup_probe_role strict +trap - EXIT diff --git a/tests/test_document_record_idempotency_isolation_postgres.sh b/tests/test_document_record_idempotency_isolation_postgres.sh new file mode 100644 index 000000000..557d9626c --- /dev/null +++ b/tests/test_document_record_idempotency_isolation_postgres.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0021_document_record_persistence.sql \ + database/migrations/0022_document_record_evidence_unique_keys.sql \ + database/migrations/0023_document_record_canonical_encoding.sql \ + database/migrations/0024_document_record_idempotent_persistence.sql; do + if [[ ! -f "${migration}" ]]; then + echo "required document-record idempotency migration is missing: ${migration}" >&2 + exit 1 + fi + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +set +e +repeatable_read_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 2>&1 <<'SQL' +BEGIN ISOLATION LEVEL REPEATABLE READ; +SELECT * +FROM public.persist_document_record_once( + NULL::uuid, + NULL::text, + NULL::uuid, + NULL::text, + NULL::text, + NULL::text, + NULL::text, + NULL::text, + NULL::text, + NULL::text, + NULL::text, + NULL::text, + NULL::text, + NULL::text, + NULL::timestamptz, + NULL::text, + NULL::text, + NULL::text, + NULL::text, + NULL::text +); +ROLLBACK; +SQL +)" +repeatable_read_status=$? +set -e + +if [[ ${repeatable_read_status} -eq 0 \ + || "${repeatable_read_output}" != *"document persistence idempotency requires read committed transaction isolation"* ]]; then + echo "document-record replay boundary did not fail closed under REPEATABLE READ: ${repeatable_read_output}" >&2 + exit 1 +fi + +read_committed="$(psql "${DATABASE_URL}" -Atqc "SHOW transaction_isolation;")" +if [[ "${read_committed}" != "read committed" ]]; then + echo "Foundation PostgreSQL acceptance does not exercise the supported retry isolation: ${read_committed}" >&2 + exit 1 +fi diff --git a/tests/test_document_record_idempotency_postcommit_recovery_contract.py b/tests/test_document_record_idempotency_postcommit_recovery_contract.py new file mode 100644 index 000000000..46857975b --- /dev/null +++ b/tests/test_document_record_idempotency_postcommit_recovery_contract.py @@ -0,0 +1,68 @@ +"""Regression contracts for post-commit document-record recovery acceptance.""" + +from pathlib import Path + + +COMPANION = Path(__file__).with_name( + "document_record_idempotency_postcommit_recovery_companion.sh" +) + + +def _companion_source() -> str: + return COMPANION.read_text(encoding="utf-8") + + +def _shell_function(source: str, name: str, following_marker: str) -> str: + start = source.index(f"{name}() {{") + end = source.index(following_marker, start) + return source[start:end] + + +def test_recovery_termination_binds_checked_backend_identity() -> None: + """Termination must consume the same backend identity that observation accepted.""" + + source = _companion_source() + termination = _shell_function( + source, + "terminate_captured_backend", + "\n}\n\ncleanup()", + ) + + # Fail closed on any second lexical occurrence, including an alternate call + # hidden behind PostgreSQL whitespace/comments or a separate helper. + assert source.count("pg_terminate_backend") == 1 + assert termination.count("pg_terminate_backend") == 1 + assert "pg_catalog.pg_terminate_backend(pid)" in termination + assert "WHERE pid = ${backend_pid}" in termination + assert "application_name = '${APPLICATION_NAME}'" in termination + assert ( + "extract(epoch FROM backend_start)::text = '${backend_start_epoch}'" + in termination + ) + assert '[[ "${termination_receipt}" == "1|true" ]]' in termination + + assert "extract(epoch FROM activity.backend_start)::text" in source + + +def test_exit_cleanup_uses_the_same_guarded_backend_identity() -> None: + """Abort cleanup must not introduce another backend-termination path.""" + + source = _companion_source() + cleanup = _shell_function(source, "cleanup", "\n}\ntrap cleanup EXIT") + + assert "terminate_captured_backend" in cleanup + assert "pg_terminate_backend" not in cleanup + assert "backend_start_epoch" in cleanup + assert "|| true" in cleanup + + +def test_recovery_session_name_is_execution_unique() -> None: + """Concurrent or leaked acceptance sessions must not share one global marker.""" + + source = _companion_source() + + assert "uuid.uuid4().hex[:24]" in source + assert ( + 'APPLICATION_NAME="orgmetra_document_idempotency_lost_${APPLICATION_SUFFIX}"' + in source + ) diff --git a/tests/test_document_record_idempotency_postgres.sh b/tests/test_document_record_idempotency_postgres.sh new file mode 100644 index 000000000..b588f31a7 --- /dev/null +++ b/tests/test_document_record_idempotency_postgres.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0021_document_record_persistence.sql \ + database/migrations/0022_document_record_evidence_unique_keys.sql \ + database/migrations/0023_document_record_canonical_encoding.sql \ + database/migrations/0024_document_record_idempotent_persistence.sql; do + if [[ ! -f "${migration}" ]]; then + echo "required document-record idempotency migration is missing: ${migration}" >&2 + exit 1 + fi + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +TENANT_ID="10000000-0000-7000-8000-000000000001" +OTHER_TENANT_ID="20000000-0000-7000-8000-000000000002" +PERSON_REFERENCE="person_record:00000000-0000-4000-8000-000000000011" +EMPLOYMENT_REFERENCE="employment_record:00000000-0000-4000-8000-000000000021" +UPLOADER="actor:00000000-0000-4000-8000-000000000061" +PERSISTED_BY="actor:00000000-0000-4000-8000-000000000062" +RETENTION_REFERENCE="retention_policy:00000000-0000-4000-8000-000000000051" +ARTIFACT_DIGEST="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +SOURCE_DIGEST="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +RETENTION_DIGEST="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +APPLICATION_DIGEST="eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +CONFLICTING_APPLICATION_DIGEST="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" +IDEMPOTENCY_KEY="document-record-persist-00000000-0000-4000-8000-000000000101" +CONCURRENT_KEY="document-record-persist-00000000-0000-4000-8000-000000000102" + +IFS='|' read -r RECEIVED_AT EVIDENCE_RECORDED_AT < <(psql "${DATABASE_URL}" -Atqc " +SELECT + to_char((pg_catalog.transaction_timestamp() - interval '2 minutes') AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'), + to_char((pg_catalog.transaction_timestamp() - interval '1 minute') AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'); +") + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <&2 + exit 1 +fi + +counts="$(psql "${DATABASE_URL}" -Atqc " +SELECT + (SELECT count(*) FROM document_record WHERE tenant_record_id = '${TENANT_ID}'::uuid AND document_record_id = '${DOCUMENT_ID}'::uuid)::text + || '|' || + (SELECT count(*) FROM document_record_persist_receipt WHERE tenant_record_id = '${TENANT_ID}'::uuid AND idempotency_key = '${IDEMPOTENCY_KEY}')::text; +")" +if [[ "${counts}" != "1|1" ]]; then + echo "same semantic retry duplicated durable document or receipt state: ${counts}" >&2 + exit 1 +fi + +returned_recorded_at_epoch="${first_result##*|}" +durable_recorded_at_epochs="$(psql "${DATABASE_URL}" -Atqc " +SELECT + extract(epoch FROM persisted.recorded_at)::text || '|' || + extract(epoch FROM receipt.recorded_at)::text +FROM document_record AS persisted +JOIN document_record_persist_receipt AS receipt + ON receipt.tenant_record_id = persisted.tenant_record_id + AND receipt.document_record_id = persisted.document_record_id +WHERE receipt.tenant_record_id = '${TENANT_ID}'::uuid + AND receipt.idempotency_key = '${IDEMPOTENCY_KEY}'; +")" +if [[ -z "${returned_recorded_at_epoch}" || "${durable_recorded_at_epochs}" != "${returned_recorded_at_epoch}|${returned_recorded_at_epoch}" ]]; then + echo "replay result database time is not bound to the original durable document and receipt time: returned=${returned_recorded_at_epoch} durable=${durable_recorded_at_epochs}" >&2 + exit 1 +fi + +set +e +conflict_output="$(with_tenant "${TENANT_ID}" "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 -v canonical_evidence="${CANONICAL_EVIDENCE}" \ + -c "$(persist_sql "${IDEMPOTENCY_KEY}" "${DOCUMENT_ID}" "${DOCUMENT_REFERENCE}" "${ARTIFACT_REFERENCE}" "${AUDIT_REFERENCE}" "${OUTBOX_REFERENCE}" "${CONFLICTING_APPLICATION_DIGEST}" "${CANONICAL_EVIDENCE}" "${EVIDENCE_DIGEST}")" 2>&1)" +conflict_status=$? +set -e +if [[ ${conflict_status} -eq 0 || "${conflict_output}" != *"idempotency key is bound to a different document persistence command"* ]]; then + echo "same idempotency key accepted a different semantic command: ${conflict_output}" >&2 + exit 1 +fi + +CONCURRENT_DOCUMENT_ID="00000000-0000-7000-8000-000000000132" +CONCURRENT_DOCUMENT_REFERENCE="document_record:00000000-0000-4000-8000-000000000132" +CONCURRENT_ARTIFACT_REFERENCE="document_artifact:00000000-0000-4000-8000-000000000142" +CONCURRENT_AUDIT_REFERENCE="audit_event:00000000-0000-4000-8000-000000000173" +CONCURRENT_OUTBOX_REFERENCE="outbox_event:00000000-0000-4000-8000-000000000174" +mapfile -t concurrent_evidence_parts < <(build_evidence "${CONCURRENT_DOCUMENT_REFERENCE}" "${CONCURRENT_ARTIFACT_REFERENCE}") +CONCURRENT_EVIDENCE="${concurrent_evidence_parts[0]}" +CONCURRENT_EVIDENCE_DIGEST="${concurrent_evidence_parts[1]}" +CONCURRENT_SQL="$(persist_sql "${CONCURRENT_KEY}" "${CONCURRENT_DOCUMENT_ID}" "${CONCURRENT_DOCUMENT_REFERENCE}" "${CONCURRENT_ARTIFACT_REFERENCE}" "${CONCURRENT_AUDIT_REFERENCE}" "${CONCURRENT_OUTBOX_REFERENCE}" "${APPLICATION_DIGEST}" "${CONCURRENT_EVIDENCE}" "${CONCURRENT_EVIDENCE_DIGEST}")" +CONCURRENCY_DIR="$(mktemp -d)" +FIRST_INPUT="${CONCURRENCY_DIR}/first-input" +FIRST_OUTPUT="${CONCURRENCY_DIR}/first-output" +SECOND_OUTPUT="${CONCURRENCY_DIR}/second-output" +mkfifo "${FIRST_INPUT}" +first_client_pid="" +second_client_pid="" +PROBE_ROLE_SUFFIX="$(python3 - <<'PY' +import uuid +print(uuid.uuid4().hex[:24]) +PY +)" +PROBE_ROLE="orgmetra_document_receipt_probe_${PROBE_ROLE_SUFFIX}" + +cleanup_probe_role() { + local mode="${1:-strict}" + local role_exists + + if ! role_exists="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 -c \ + "SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${PROBE_ROLE}';" 2>/dev/null)"; then + if [[ "${mode}" == "best-effort" ]]; then + return 0 + fi + echo "could not verify temporary RLS probe-role cleanup" >&2 + return 1 + fi + if [[ "${role_exists}" != "1" ]]; then + return 0 + fi + if psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 >/dev/null 2>&1 <&2 + return 1 +} + +cleanup() { + exec 3>&- 2>/dev/null || true + if [[ -n "${second_client_pid}" ]] && kill -0 "${second_client_pid}" 2>/dev/null; then + kill "${second_client_pid}" 2>/dev/null || true + fi + if [[ -n "${first_client_pid}" ]] && kill -0 "${first_client_pid}" 2>/dev/null; then + kill "${first_client_pid}" 2>/dev/null || true + fi + cleanup_probe_role best-effort + rm -rf "${CONCURRENCY_DIR}" +} +trap cleanup EXIT + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID} ${PGOPTIONS:-}" \ +PGAPPNAME=orgmetra_document_idempotency_first \ +psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 \ + -v canonical_evidence="${CONCURRENT_EVIDENCE}" <"${FIRST_INPUT}" >"${FIRST_OUTPUT}" 2>&1 & +first_client_pid=$! +exec 3>"${FIRST_INPUT}" +cat >&3 </dev/null; then + break + fi + sleep 0.05 +done +if [[ ! "${FIRST_BACKEND_PID}" =~ ^[0-9]+$ ]]; then + echo "first concurrent session did not reach an observable held transaction boundary" >&2 + cat "${FIRST_OUTPUT}" >&2 + exit 1 +fi + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID} ${PGOPTIONS:-}" \ +PGAPPNAME=orgmetra_document_idempotency_second \ +psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 \ + -v canonical_evidence="${CONCURRENT_EVIDENCE}" -c "${CONCURRENT_SQL}" >"${SECOND_OUTPUT}" 2>&1 & +second_client_pid=$! + +advisory_wait_observed=false +second_deadline=$((SECONDS + 10)) +while (( SECONDS < second_deadline )); do + advisory_wait_count="$(psql "${DATABASE_URL}" -Atqc " +SELECT count(*) +FROM pg_catalog.pg_stat_activity AS activity +JOIN pg_catalog.pg_locks AS lock_state + ON lock_state.pid = activity.pid +WHERE activity.application_name = 'orgmetra_document_idempotency_second' + AND lock_state.locktype = 'advisory' + AND NOT lock_state.granted + AND ${FIRST_BACKEND_PID} = ANY(pg_catalog.pg_blocking_pids(activity.pid)); +")" + if [[ "${advisory_wait_count}" == "1" ]]; then + advisory_wait_observed=true + break + fi + if ! kill -0 "${second_client_pid}" 2>/dev/null; then + break + fi + sleep 0.05 +done +if [[ "${advisory_wait_observed}" != "true" ]]; then + echo "second concurrent session did not demonstrably wait on the first session's advisory lock" >&2 + cat "${SECOND_OUTPUT}" >&2 + exit 1 +fi + +printf 'COMMIT;\n\\q\n' >&3 +exec 3>&- +wait "${first_client_pid}" +first_client_pid="" +wait "${second_client_pid}" +second_client_pid="" +first_concurrent_result="$(grep -F 'document_record:' "${FIRST_OUTPUT}" | head -n 1)" +second_concurrent_result="$(grep -F 'document_record:' "${SECOND_OUTPUT}" | head -n 1)" +if [[ -z "${first_concurrent_result}" || "${first_concurrent_result}" != "${second_concurrent_result}" ]]; then + echo "concurrent same-semantic attempts did not converge: first=${first_concurrent_result} second=${second_concurrent_result}" >&2 + exit 1 +fi + +concurrent_counts="$(psql "${DATABASE_URL}" -Atqc " +SELECT + (SELECT count(*) FROM document_record WHERE tenant_record_id = '${TENANT_ID}'::uuid AND document_record_id = '${CONCURRENT_DOCUMENT_ID}'::uuid)::text + || '|' || + (SELECT count(*) FROM document_record_persist_receipt WHERE tenant_record_id = '${TENANT_ID}'::uuid AND idempotency_key = '${CONCURRENT_KEY}')::text; +")" +if [[ "${concurrent_counts}" != "1|1" ]]; then + echo "concurrent retry duplicated durable state: ${concurrent_counts}" >&2 + exit 1 +fi + +active_test_connections="$(psql "${DATABASE_URL}" -Atqc " +SELECT count(*) FROM pg_stat_activity +WHERE application_name IN ('orgmetra_document_idempotency_first', 'orgmetra_document_idempotency_second'); +")" +if [[ "${active_test_connections}" != "0" ]]; then + echo "idempotency acceptance leaked PostgreSQL connections: ${active_test_connections}" >&2 + exit 1 +fi + +rls_state="$(psql "${DATABASE_URL}" -Atqc " +SELECT relrowsecurity::text || '|' || relforcerowsecurity::text +FROM pg_class WHERE oid = 'document_record_persist_receipt'::regclass; +")" +if [[ "${rls_state}" != "true|true" ]]; then + echo "document-record idempotency receipt is not FORCE-RLS protected: ${rls_state}" >&2 + exit 1 +fi + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <&2 + exit 1 +fi + +other_tenant_receipts="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 <&2 + exit 1 +fi + +cross_tenant_update="$(psql "${DATABASE_URL}" -Atq -v ON_ERROR_STOP=1 <&2 + exit 1 +fi + +cleanup_probe_role strict + +set +e +mutation_output="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c " +UPDATE document_record_persist_receipt +SET semantic_command_digest_sha256 = '${CONFLICTING_APPLICATION_DIGEST}' +WHERE tenant_record_id = '${TENANT_ID}'::uuid AND idempotency_key = '${IDEMPOTENCY_KEY}';" 2>&1)" +mutation_status=$? +set -e +if [[ ${mutation_status} -eq 0 || "${mutation_output}" != *"append-only"* ]]; then + echo "document-record idempotency receipt was mutable: ${mutation_output}" >&2 + exit 1 +fi diff --git a/tests/test_document_record_idempotency_tenant_context_postgres.sh b/tests/test_document_record_idempotency_tenant_context_postgres.sh new file mode 100644 index 000000000..979c3853b --- /dev/null +++ b/tests/test_document_record_idempotency_tenant_context_postgres.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0021_document_record_persistence.sql \ + database/migrations/0022_document_record_evidence_unique_keys.sql \ + database/migrations/0023_document_record_canonical_encoding.sql \ + database/migrations/0024_document_record_idempotent_persistence.sql; do + if [[ ! -f "${migration}" ]]; then + echo "required document-record tenant-context migration is missing: ${migration}" >&2 + exit 1 + fi + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +SESSION_TENANT_ID="10000000-0000-7000-8000-000000000001" +REQUESTED_TENANT_ID="20000000-0000-7000-8000-000000000002" +DOCUMENT_ID="00000000-0000-7000-8000-000000000231" +DOCUMENT_REFERENCE="document_record:00000000-0000-4000-8000-000000000231" +PERSON_REFERENCE="person_record:00000000-0000-4000-8000-000000000211" +EMPLOYMENT_REFERENCE="employment_record:00000000-0000-4000-8000-000000000221" +UPLOADER="actor:00000000-0000-4000-8000-000000000261" +PERSISTED_BY="actor:00000000-0000-4000-8000-000000000262" +ARTIFACT_REFERENCE="document_artifact:00000000-0000-4000-8000-000000000241" +RETENTION_REFERENCE="retention_policy:00000000-0000-4000-8000-000000000251" +AUDIT_REFERENCE="audit_event:00000000-0000-4000-8000-000000000271" +OUTBOX_REFERENCE="outbox_event:00000000-0000-4000-8000-000000000272" +IDEMPOTENCY_KEY="document-record-persist-00000000-0000-4000-8000-000000000201" +ARTIFACT_DIGEST="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +SOURCE_DIGEST="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +RETENTION_DIGEST="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +APPLICATION_DIGEST="eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + +IFS='|' read -r RECEIVED_AT EVIDENCE_RECORDED_AT < <(psql "${DATABASE_URL}" -Atqc " +SELECT + to_char((pg_catalog.transaction_timestamp() - interval '2 minutes') AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'), + to_char((pg_catalog.transaction_timestamp() - interval '1 minute') AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'); +") +export RECEIVED_AT EVIDENCE_RECORDED_AT + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <&1 <&2 + exit 1 +fi + +cross_tenant_rows="$(psql "${DATABASE_URL}" -Atqc " +SELECT + (SELECT count(*) FROM document_record + WHERE tenant_record_id = '${REQUESTED_TENANT_ID}'::uuid + AND document_record_id = '${DOCUMENT_ID}'::uuid)::text + || '|' || + (SELECT count(*) FROM document_record_persist_receipt + WHERE tenant_record_id = '${REQUESTED_TENANT_ID}'::uuid + AND idempotency_key = '${IDEMPOTENCY_KEY}')::text; +")" +if [[ "${cross_tenant_rows}" != "0|0" ]]; then + echo "cross-tenant persistence left durable document or receipt state: ${cross_tenant_rows}" >&2 + exit 1 +fi + +echo "document-record idempotency tenant-context contract passed" diff --git a/tests/validate_repository.py b/tests/validate_repository.py index 498974a01..8a83b2485 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -55,9 +55,11 @@ "docs/adr/0013-governed-requisition-review-packet.md", "docs/adr/0014-job-analysis-snapshot-persistence.md", "docs/adr/0107-document-record-persistence.md", + "docs/adr/0309-document-record-idempotent-persistence.md", "docs/doctoring/REFERENCES.md", "docs/doctoring/document-record-persistence-references.md", "docs/traceability/document-record-persistence.md", + "docs/traceability/document-record-idempotent-persistence.md", "docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md", "docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md", "database/migrations/0001_foundation_schema.sql", @@ -76,6 +78,7 @@ "database/migrations/0021_document_record_persistence.sql", "database/migrations/0022_document_record_evidence_unique_keys.sql", "database/migrations/0023_document_record_canonical_encoding.sql", + "database/migrations/0024_document_record_idempotent_persistence.sql", "packages/hris-kernel/src/orgmetra_hris_kernel/audit.py", "packages/hris-kernel/tests/test_audit_outbox.py", "schemas/openapi.yaml", @@ -100,6 +103,12 @@ "tests/test_document_record_canonical_bytes_postgres.sh", "tests/test_document_record_evidence_unique_keys_postgres.sh", "tests/test_document_record_persistence_postgres.sh", + "tests/test_document_record_idempotency_postgres.sh", + "tests/test_document_record_idempotency_function_acl_postgres.sh", + "tests/test_document_record_idempotency_isolation_postgres.sh", + "tests/test_document_record_idempotency_tenant_context_postgres.sh", + "tests/document_record_idempotency_postcommit_recovery_companion.sh", + "tests/test_document_record_idempotency_postcommit_recovery_contract.py", "tests/validate_repository.py", ]