From 69146511500fcbf93643f203d6fa47ff62244c29 Mon Sep 17 00:00:00 2001 From: Jason Lee <56489493+jason931225@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:48:07 -0400 Subject: [PATCH] feat(ontology): canonical receipts record whose they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0223 gave `ont_action_command_receipts` an `owner` and a `target`, but no writer set them. `owner` DEFAULTs to `'ontology.action'`, so every canonical receipt — Company revise, HR appoint, PayRun decide — claimed to belong to the pre-existing instance-action path. That is worse than the column not existing. An absent column is a known gap; a populated one reads as an answer, and this one answered wrongly for six of the seven writers. DERIVED FROM THE COMMAND'S QUERY, not from `action_key`. Every canonical query already implements `dispatch_target()` — the same value the projected dispatch path uses — so the attribution is a lookup, not a second mapping to drift: let receipt_target = command.query.dispatch_target(); let receipt_owner = ReceiptOwner::Canonical(receipt_target.object()); `action_key` cannot do this job, and I tried it first. Its own doc says it is "unique only per object type": this suite's commands carry a bare `"revise"`, which names no target at all, and `employment.rs` reassigns org units under `"internal.reassign_org_unit"`, which is not a dispatch target by construction. Parsing it would have failed closed on a real production path. `ontology/rest` is deliberately UNCHANGED. It is the instance-action path — the rows the widening's DEFAULT was written for — so `'ontology.action'` with a NULL target is the truth there, and 0223's CHECK requires exactly that pairing. Proven against real PostgreSQL: a real Company port write -> owner='company', target='company.revise' every DispatchTarget -> storable under the object that owns it, looped over DispatchTarget::ALL so a fourteenth target cannot arrive unattributed no canonical receipt -> falls back to the instance-action default Mutation-proven: removing the two binds from `company.rs` turns the port test red with `left: "ontology.action"` — the exact wrong attribution this fixes. All five canonical port suites, the PayRun port suite and the widening suite pass. WHAT THIS DOES NOT DO. The DEFAULT stays. Dropping it belongs with the change that makes `ontology/rest` pass `owner` explicitly, and dropping it now would break that writer on the next deploy. Per-row attribution is now TRUE where it is written, but nothing yet stops a crate writing a row it does not own — that is the receipt store's writer boundary, bounded to three crates by #829 and still without per-row enforcement. Co-Authored-By: Claude Opus 5 --- .../canonical-adapter-postgres/src/company.rs | 20 +++++++++-- .../src/employment.rs | 20 +++++++++-- .../src/job_position.rs | 20 +++++++++-- .../src/org_unit.rs | 20 +++++++++-- .../canonical-adapter-postgres/src/person.rs | 20 +++++++++-- .../tests/company_port_as_runtime_role.rs | 34 +++++++++++++++++++ .../tests/receipt_owner_widening.rs | 33 ++++++++++++++++++ .../payroll/adapter-postgres/src/pay_run.rs | 20 +++++++++-- docs/program/executed-tests-baseline.json | 4 +-- 9 files changed, 177 insertions(+), 14 deletions(-) diff --git a/backend/crates/ontology/canonical-adapter-postgres/src/company.rs b/backend/crates/ontology/canonical-adapter-postgres/src/company.rs index 3228c95b1..61528ac62 100644 --- a/backend/crates/ontology/canonical-adapter-postgres/src/company.rs +++ b/backend/crates/ontology/canonical-adapter-postgres/src/company.rs @@ -246,10 +246,24 @@ impl PgCompanyPort { // The receipt store, and with it the tenant-global command-id namespace // this port shares with every other receipt owner. + // Attribute the receipt to the object whose action it records. + // + // DERIVED from the command's own query, which already implements + // `dispatch_target()` -- the same value the projected-dispatch path uses. + // NOT from `action_key`: that is "unique only per object type" (a bare + // "revise"), so it cannot name a target on its own, and the internal + // reassign path carries "internal.reassign_org_unit", which names none at + // all. The query knows; the string does not. + // + // Without this the row takes the `owner` DEFAULT of 'ontology.action', + // filing a canonical receipt under the pre-existing instance-action path + // -- a wrong attribution recorded as fact. + let receipt_target = command.query.dispatch_target(); + let receipt_owner = ReceiptOwner::Canonical(receipt_target.object()); sqlx::query( "INSERT INTO ont_action_command_receipts \ - (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at, owner, target) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(org) .bind(command_uuid) @@ -259,6 +273,8 @@ impl PgCompanyPort { .bind(&command.action_key) .bind(command.object_type_id) .bind(created_at) + .bind(receipt_owner.as_str()) + .bind(receipt_target.as_str()) .execute(&mut *tx) .await?; diff --git a/backend/crates/ontology/canonical-adapter-postgres/src/employment.rs b/backend/crates/ontology/canonical-adapter-postgres/src/employment.rs index 9bd9ceaf4..410519c84 100644 --- a/backend/crates/ontology/canonical-adapter-postgres/src/employment.rs +++ b/backend/crates/ontology/canonical-adapter-postgres/src/employment.rs @@ -700,10 +700,24 @@ pub async fn write_in_tx( // The receipt store, and with it the tenant-global command-id namespace // this port shares with every other receipt owner. + // Attribute the receipt to the object whose action it records. + // + // DERIVED from the command's own query, which already implements + // `dispatch_target()` -- the same value the projected-dispatch path uses. + // NOT from `action_key`: that is "unique only per object type" (a bare + // "revise"), so it cannot name a target on its own, and the internal + // reassign path carries "internal.reassign_org_unit", which names none at + // all. The query knows; the string does not. + // + // Without this the row takes the `owner` DEFAULT of 'ontology.action', + // filing a canonical receipt under the pre-existing instance-action path + // -- a wrong attribution recorded as fact. + let receipt_target = command.query.dispatch_target(); + let receipt_owner = ReceiptOwner::Canonical(receipt_target.object()); sqlx::query( "INSERT INTO ont_action_command_receipts \ - (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at, owner, target) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(org) .bind(command_uuid) @@ -713,6 +727,8 @@ pub async fn write_in_tx( .bind(&command.action_key) .bind(command.object_type_id) .bind(created_at) + .bind(receipt_owner.as_str()) + .bind(receipt_target.as_str()) .execute(tx.as_mut()) .await?; diff --git a/backend/crates/ontology/canonical-adapter-postgres/src/job_position.rs b/backend/crates/ontology/canonical-adapter-postgres/src/job_position.rs index c815aebcd..072aca9cd 100644 --- a/backend/crates/ontology/canonical-adapter-postgres/src/job_position.rs +++ b/backend/crates/ontology/canonical-adapter-postgres/src/job_position.rs @@ -444,10 +444,24 @@ impl PgJobPositionPort { // The receipt store, and with it the tenant-global command-id // namespace this port shares with every other receipt owner. + // Attribute the receipt to the object whose action it records. + // + // DERIVED from the command's own query, which already implements + // `dispatch_target()` -- the same value the projected-dispatch path uses. + // NOT from `action_key`: that is "unique only per object type" (a bare + // "revise"), so it cannot name a target on its own, and the internal + // reassign path carries "internal.reassign_org_unit", which names none at + // all. The query knows; the string does not. + // + // Without this the row takes the `owner` DEFAULT of 'ontology.action', + // filing a canonical receipt under the pre-existing instance-action path + // -- a wrong attribution recorded as fact. + let receipt_target = command.query.dispatch_target(); + let receipt_owner = ReceiptOwner::Canonical(receipt_target.object()); sqlx::query( "INSERT INTO ont_action_command_receipts \ - (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at, owner, target) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(org) .bind(command_uuid) @@ -457,6 +471,8 @@ impl PgJobPositionPort { .bind(&command.action_key) .bind(command.object_type_id) .bind(created_at) + .bind(receipt_owner.as_str()) + .bind(receipt_target.as_str()) .execute(&mut *tx) .await?; diff --git a/backend/crates/ontology/canonical-adapter-postgres/src/org_unit.rs b/backend/crates/ontology/canonical-adapter-postgres/src/org_unit.rs index 62d8a207a..0314b0571 100644 --- a/backend/crates/ontology/canonical-adapter-postgres/src/org_unit.rs +++ b/backend/crates/ontology/canonical-adapter-postgres/src/org_unit.rs @@ -309,10 +309,24 @@ impl PgOrgUnitPort { // The receipt store, and with it the tenant-global command-id // namespace this port shares with every other receipt owner. + // Attribute the receipt to the object whose action it records. + // + // DERIVED from the command's own query, which already implements + // `dispatch_target()` -- the same value the projected-dispatch path uses. + // NOT from `action_key`: that is "unique only per object type" (a bare + // "revise"), so it cannot name a target on its own, and the internal + // reassign path carries "internal.reassign_org_unit", which names none at + // all. The query knows; the string does not. + // + // Without this the row takes the `owner` DEFAULT of 'ontology.action', + // filing a canonical receipt under the pre-existing instance-action path + // -- a wrong attribution recorded as fact. + let receipt_target = command.query.dispatch_target(); + let receipt_owner = ReceiptOwner::Canonical(receipt_target.object()); sqlx::query( "INSERT INTO ont_action_command_receipts \ - (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at, owner, target) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(org) .bind(command_uuid) @@ -322,6 +336,8 @@ impl PgOrgUnitPort { .bind(&command.action_key) .bind(command.object_type_id) .bind(created_at) + .bind(receipt_owner.as_str()) + .bind(receipt_target.as_str()) .execute(&mut *tx) .await?; diff --git a/backend/crates/ontology/canonical-adapter-postgres/src/person.rs b/backend/crates/ontology/canonical-adapter-postgres/src/person.rs index a1ae44e83..d2ca430fe 100644 --- a/backend/crates/ontology/canonical-adapter-postgres/src/person.rs +++ b/backend/crates/ontology/canonical-adapter-postgres/src/person.rs @@ -313,10 +313,24 @@ impl PgPersonPort { // The receipt store, and with it the tenant-global command-id // namespace this port shares with every other receipt owner. + // Attribute the receipt to the object whose action it records. + // + // DERIVED from the command's own query, which already implements + // `dispatch_target()` -- the same value the projected-dispatch path uses. + // NOT from `action_key`: that is "unique only per object type" (a bare + // "revise"), so it cannot name a target on its own, and the internal + // reassign path carries "internal.reassign_org_unit", which names none at + // all. The query knows; the string does not. + // + // Without this the row takes the `owner` DEFAULT of 'ontology.action', + // filing a canonical receipt under the pre-existing instance-action path + // -- a wrong attribution recorded as fact. + let receipt_target = command.query.dispatch_target(); + let receipt_owner = ReceiptOwner::Canonical(receipt_target.object()); sqlx::query( "INSERT INTO ont_action_command_receipts \ - (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at, owner, target) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", ) .bind(org) .bind(command_uuid) @@ -326,6 +340,8 @@ impl PgPersonPort { .bind(&command.action_key) .bind(command.object_type_id) .bind(created_at) + .bind(receipt_owner.as_str()) + .bind(receipt_target.as_str()) .execute(&mut *tx) .await?; diff --git a/backend/crates/ontology/canonical-adapter-postgres/tests/company_port_as_runtime_role.rs b/backend/crates/ontology/canonical-adapter-postgres/tests/company_port_as_runtime_role.rs index 503fb4be4..8dd6645ab 100644 --- a/backend/crates/ontology/canonical-adapter-postgres/tests/company_port_as_runtime_role.rs +++ b/backend/crates/ontology/canonical-adapter-postgres/tests/company_port_as_runtime_role.rs @@ -790,3 +790,37 @@ async fn a_stored_receipt_naming_no_dispatch_target_is_refused(owner_pool: PgPoo "a receipt the roster cannot read must be refused, never replayed; got {refused:?}" ); } + +/// The port records WHOSE receipt this is, not the instance-action default. +/// +/// `ont_action_command_receipts.owner` DEFAULTs to `'ontology.action'`, so until +/// the writers passed it explicitly every canonical receipt claimed to belong to +/// the pre-existing instance-action path. A wrong attribution recorded as fact is +/// worse than an absent column, because it reads as an answer. +/// +/// The value is derived from the command's own `query.dispatch_target()`, never +/// from `action_key` -- this suite's commands carry `action_key: "revise"`, which +/// is unique only per object type and names no target on its own. +#[sqlx::test(migrations = "../../platform/db/migrations")] +async fn a_company_receipt_is_attributed_to_company(owner_pool: PgPool) { + let (org, actor, port) = fixture(&owner_pool).await; + execute(&port, command(org, actor, revise("주식회사 아크메"))) + .await + .expect("the revise must land"); + + let (owner, target): (String, Option) = + sqlx::query_as("SELECT owner, target FROM ont_action_command_receipts WHERE org_id = $1") + .bind(*org.as_uuid()) + .fetch_one(&owner_pool) + .await + .unwrap(); + assert_eq!( + owner, "company", + "the receipt must name the object that owns it" + ); + assert_eq!( + target.as_deref(), + Some("company.revise"), + "and the dispatch target it records" + ); +} diff --git a/backend/crates/ontology/canonical-adapter-postgres/tests/receipt_owner_widening.rs b/backend/crates/ontology/canonical-adapter-postgres/tests/receipt_owner_widening.rs index 9696dc4ba..bf296b4d2 100644 --- a/backend/crates/ontology/canonical-adapter-postgres/tests/receipt_owner_widening.rs +++ b/backend/crates/ontology/canonical-adapter-postgres/tests/receipt_owner_widening.rs @@ -149,3 +149,36 @@ async fn a_receipt_written_without_owner_defaults_to_ontology_action(pool: PgPoo "a defaulted receipt must carry no dispatch target" ); } + +/// A canonical port records its OWN owner and target, not the default. +/// +/// `owner` DEFAULTs to `'ontology.action'`, so before the writers passed it +/// explicitly every canonical receipt claimed to belong to the pre-existing +/// instance-action path. That is a wrong attribution recorded as fact — worse +/// than the column not existing, because it reads as an answer. +/// +/// Written over `DispatchTarget::ALL` rather than a sample: every dispatch +/// target must be attributable to the object that owns it, so a fourteenth +/// target cannot arrive unattributed. +#[sqlx::test(migrations = "../../platform/db/migrations")] +async fn every_dispatch_target_attributes_to_its_owning_object(pool: PgPool) { + let (org, actor) = seed_actor(&pool).await; + for target in DispatchTarget::ALL { + let owner = ReceiptOwner::Canonical(target.object()); + insert_receipt(&pool, org, actor, owner.as_str(), Some(target.as_str())) + .await + .unwrap_or_else(|err| panic!("{target:?} must be storable under {owner:?}: {err}")); + } + let mislabelled: i64 = sqlx::query_scalar( + "SELECT count(*) FROM ont_action_command_receipts \ + WHERE org_id = $1 AND owner = 'ontology.action'", + ) + .bind(org) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + mislabelled, 0, + "no canonical receipt may fall back to the instance-action default" + ); +} diff --git a/backend/crates/payroll/adapter-postgres/src/pay_run.rs b/backend/crates/payroll/adapter-postgres/src/pay_run.rs index 922f6a611..e4f580c34 100644 --- a/backend/crates/payroll/adapter-postgres/src/pay_run.rs +++ b/backend/crates/payroll/adapter-postgres/src/pay_run.rs @@ -681,10 +681,24 @@ impl PgPayRunPort { // this port shares with every other receipt owner. `created_at` is // supplied rather than defaulted: 0177 declares it `NOT NULL` with NO // DEFAULT, so an INSERT that omits it is a `23502`. + // Attribute the receipt to the object whose action it records. + // + // DERIVED from the command's own query, which already implements + // `dispatch_target()` -- the same value the projected-dispatch path uses. + // NOT from `action_key`: that is "unique only per object type" (a bare + // "revise"), so it cannot name a target on its own, and the internal + // reassign path carries "internal.reassign_org_unit", which names none at + // all. The query knows; the string does not. + // + // Without this the row takes the `owner` DEFAULT of 'ontology.action', + // filing a canonical receipt under the pre-existing instance-action path + // -- a wrong attribution recorded as fact. + let receipt_target = command.query.dispatch_target(); + let receipt_owner = ReceiptOwner::Canonical(receipt_target.object()); let created_at: OffsetDateTime = sqlx::query_scalar( "INSERT INTO ont_action_command_receipts \ - (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, now()) RETURNING created_at", + (org_id, command_id, actor_id, payload_digest, receipt, action_key, object_type_id, created_at, owner, target) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9) RETURNING created_at", ) .bind(org) .bind(command_uuid) @@ -693,6 +707,8 @@ impl PgPayRunPort { .bind(&result) .bind(&command.action_key) .bind(command.object_type_id) + .bind(receipt_owner.as_str()) + .bind(receipt_target.as_str()) .fetch_one(&mut *tx) .await?; diff --git a/docs/program/executed-tests-baseline.json b/docs/program/executed-tests-baseline.json index bf4dc4c1c..acca749ad 100644 --- a/docs/program/executed-tests-baseline.json +++ b/docs/program/executed-tests-baseline.json @@ -233,14 +233,14 @@ "backend/crates/ontology/adapter-postgres/tests/registry_rls_surfaces_as_runtime_role.rs": 16, "backend/crates/ontology/application/src/lib.rs": 17, "backend/crates/ontology/canonical-adapter-postgres/src/lib.rs": 8, - "backend/crates/ontology/canonical-adapter-postgres/tests/company_port_as_runtime_role.rs": 13, + "backend/crates/ontology/canonical-adapter-postgres/tests/company_port_as_runtime_role.rs": 14, "backend/crates/ontology/canonical-adapter-postgres/tests/employment_port_as_runtime_role.rs": 31, "backend/crates/ontology/canonical-adapter-postgres/tests/employment_reassign_as_runtime_role.rs": 2, "backend/crates/ontology/canonical-adapter-postgres/tests/employment_reassign_identity_as_runtime_role.rs": 2, "backend/crates/ontology/canonical-adapter-postgres/tests/job_position_port_as_runtime_role.rs": 14, "backend/crates/ontology/canonical-adapter-postgres/tests/org_unit_port_as_runtime_role.rs": 13, "backend/crates/ontology/canonical-adapter-postgres/tests/person_port_as_runtime_role.rs": 16, - "backend/crates/ontology/canonical-adapter-postgres/tests/receipt_owner_widening.rs": 3, + "backend/crates/ontology/canonical-adapter-postgres/tests/receipt_owner_widening.rs": 4, "backend/crates/ontology/canonical-domain/src/lib.rs": 11, "backend/crates/ontology/domain/src/lib.rs": 14, "backend/crates/ontology/rest/src/lib.rs": 19,