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,