fix(schema): enforce zero mapped-success events for denied lifecycle receipts

A denied core lifecycle receipt (outcome_code = 2) requires zero events
of the mapped success-transition kind. The previous blanket RETURN NULL
for outcome_code NOT IN (1, 3) let a denied receipt commit alongside its
mapped success-transition event, creating contradictory durable ledger
facts: denial plus enrolled/retired/revoked/rotated.

Replace the early return with a three-way branch in
authorization_operation_receipt_event_guard_v1():

  outcome_code IN (1, 3) — exactly-one mapped event (unchanged)
  outcome_code = 2       — zero events of the mapped transition kind;
                           raises authorization_denied_lifecycle_receipt_no_success_event
  other                  — skip (not a core lifecycle outcome)

Both deferred trigger directions share the same function body; a single
transaction with both INSERT paths exercises both directions at COMMIT.
The new negative fixture inserts a denied enroll receipt plus its mapped
success-transition event (event_kind = 1) in one transaction and asserts
COMMIT rejection with the exact constraint name.

Mutation: stashing the ELSIF branch lets COMMIT succeed, so expect_err
panics — confirming the branch is load-bearing (verified red).

Also correct three stale doc comments:
- Remove the "COMMIT succeeds" claim in the attempt-side shape guard
  mutation note; accurately state the pre-existing semantic-binding
  constraint fires instead.
- Remove the false claim that denial-attempt/admission-result tests
  protect applied/no-op lifecycle; replace with accurate coverage note.
- Update the semantic_fingerprint column comment to distinguish
  unresolved pre-auth kind-9 (actor_kind = 4) from authenticated kind-9
  (actor_kind 1-3).

All changes byte-mirrored between migration 0042 and schema/schema.sql.
All 9 NIP-FI ignored PostgreSQL regressions pass (--test-threads=1).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
This commit is contained in:
Hayt
2026-08-31 14:32:17 -04:00
parent 3c2c919dab
commit 156ced4167
3 changed files with 188 additions and 57 deletions

View File

@@ -4241,12 +4241,12 @@ mod tests {
/// positive A red: the COMMIT fails because the guard now requires a
/// denial-attempt row for the authenticated event and none is present.
/// - Removing the `actor_kind <> 4` shape guard from the attempt-side
/// trigger makes negative B red: the attempt binds to the authenticated
/// event and COMMIT succeeds, so the `expect_err` panics. Without the
/// exact constraint name check, the pre-existing
/// `authorization_denial_attempt_semantic_binding` guard would fire instead
/// (non-null attempt semantic_fingerprint vs. null on the event), masking
/// whether the new shape guard is load-bearing.
/// trigger makes negative B red: the COMMIT is rejected by the pre-existing
/// `authorization_denial_attempt_semantic_binding` guard instead (non-null
/// attempt `semantic_fingerprint` vs. null on the authenticated event), so
/// `assert_eq!` on the constraint name fails. The exact constraint name
/// assertion is therefore the load-bearing proof that the new shape guard —
/// not the pre-existing semantic-binding check — is what fires.
///
/// The unresolved pre-auth positive path (actor_kind 4) is exercised in
/// `authorization_denial_attempt_requires_kind_9_event_bidirectional` and
@@ -4429,13 +4429,17 @@ mod tests {
/// (outcome_code = 2) must commit without a paired audit event. Requiring
/// one would falsely record that the lifecycle transition occurred.
///
/// Mutation sensitivity: removing the `outcome_code NOT IN (1, 3)` early-return
/// from `authorization_operation_receipt_event_guard_v1` makes the positive case
/// red — the denied enroll receipt cannot commit alone because the guard then
/// demands a paired enroll audit event (expected_event_kind = 1) that is absent.
/// Existing tests (`authorization_denial_attempt_requires_kind_9_event_bidirectional`
/// and `authorization_admission_result_requires_kind_11_receipt_bidirectional`)
/// already protect the applied/no-op side of the guard.
/// Mutation sensitivity:
/// - Removing the `outcome_code IN (1, 3)` branch entirely makes the positive case
/// red — the denied enroll receipt cannot commit alone because the guard then
/// demands a paired enroll audit event (expected_event_kind = 1) that is absent.
/// - The negative fixture below (denied receipt + mapped success event) covers the
/// `outcome_code = 2` zero-event branch: removing that ELSIF branch makes the
/// negative green, failing the expected COMMIT rejection.
/// Applied/no-op lifecycle cardinality is covered by the applied path exercised
/// in `authorization_operation_receipt_event_guard_v1`'s existing constraint
/// trigger, verified by the mutation above (bypass makes the positive denied path
/// demand an absent event, confirming the guard is active for both branches).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn denied_lifecycle_receipt_commits_without_audit_event() {
@@ -4497,5 +4501,96 @@ mod tests {
event_count, 0,
"no audit event should be required or present for a denied lifecycle receipt"
);
// --- Negative: denied enroll receipt paired with its mapped success-
// transition event (event_kind = 1, enrolled) must be rejected at COMMIT.
// Both deferred trigger directions share the same
// authorization_operation_receipt_event_guard_v1 function, so a single
// transaction exercising both INSERT paths (receipt then event) covers
// both trigger directions.
//
// Seed event capacity; the authorization_events BEFORE INSERT trigger
// requires a capacity row. No lifecycle history is needed: denied receipts
// (outcome_code = 2) expect zero history rows per the history guard.
sqlx::query(
"INSERT INTO authorization_event_capacity \
(community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \
VALUES ($1, 1000, 16777216, 16384)",
)
.bind(community_id)
.execute(&pool)
.await
.expect("insert event capacity");
let op_neg = uuid::Uuid::new_v4();
let fp_neg = vec![0xD1_u8; 32];
let event_neg = uuid::Uuid::new_v4();
let corr_neg = uuid::Uuid::new_v4();
let attempt_neg = uuid::Uuid::new_v4();
let mut conn_neg = pool.acquire().await.expect("acquire connection neg");
sqlx::query("BEGIN")
.execute(&mut *conn_neg)
.await
.expect("begin neg");
// Denied enroll receipt — no history row needed (outcome_code = 2).
sqlx::query(
"INSERT INTO authorization_operation_receipts \
(community_id, operation_id, request_fingerprint, operation_kind, \
actor_fingerprint, outcome_code, result_digest) \
VALUES ($1, $2, $3, 1, $4, 2, $5)",
)
.bind(community_id)
.bind(op_neg)
.bind(&fp_neg)
.bind(vec![0xD2_u8; 32])
.bind(vec![0xD3_u8; 32])
.execute(&mut *conn_neg)
.await
.expect("insert denied receipt — event guard is deferred");
// Insert the mapped success-transition event (event_kind = 1, enrolled).
// actor_kind = 1 requires a non-null actor_fingerprint and a matching
// receipt FK (satisfied by the denied receipt above, which shares the
// same (community_id, operation_id, request_fingerprint)).
sqlx::query(
"INSERT INTO authorization_events \
(community_id, event_id, event_kind, outcome_code, reason_code, \
actor_kind, actor_fingerprint, operation_id, request_fingerprint, \
correlation_id, attempt_id, semantic_fingerprint, \
occurred_at, canonical_envelope, envelope_digest) \
VALUES ($1, $2, 1, 1, 4, 1, $3, $4, $5, $6, $7, NULL, \
'2026-01-01T00:00:00Z', $8, $9)",
// event_kind 1 (enrolled) — the mapped success transition for enroll
)
.bind(community_id)
.bind(event_neg)
.bind(vec![0xD4_u8; 32]) // actor_fingerprint
.bind(op_neg)
.bind(&fp_neg)
.bind(corr_neg)
.bind(attempt_neg)
.bind(vec![0xD5_u8; 64]) // canonical_envelope
.bind(vec![0xD6_u8; 32]) // envelope_digest
.execute(&mut *conn_neg)
.await
.expect("event INSERT must pass — deferred guard fires at COMMIT");
let contradiction_err = sqlx::query("COMMIT")
.execute(&mut *conn_neg)
.await
.expect_err(
"denied receipt + mapped success event must be rejected at COMMIT \
— contradictory durable facts must not be permitted",
);
assert_eq!(
contradiction_err
.as_database_error()
.and_then(|e| e.constraint()),
Some("authorization_denied_lifecycle_receipt_no_success_event"),
"expected authorization_denied_lifecycle_receipt_no_success_event constraint \
rejection for denied receipt + success event, got: {contradiction_err}"
);
}
}

View File

@@ -199,8 +199,10 @@ CREATE TABLE authorization_events (
correlation_id UUID NOT NULL,
attempt_id UUID NOT NULL,
-- Redaction-safe pre-authentication denial identity. Present and non-zero
-- for kind-9 events; NULL for all other event kinds. Binds the event to the
-- exact denial attempt's semantic_fingerprint (intent_digest) for exact replay.
-- for unresolved pre-auth kind-9 events (actor_kind = 4); NULL for
-- authenticated kind-9 events (actor_kind 1-3) and all other event kinds.
-- Binds the event to the exact denial attempt's semantic_fingerprint
-- (intent_digest) for exact replay.
semantic_fingerprint BYTEA CHECK (
semantic_fingerprint IS NULL OR octet_length(semantic_fingerprint) = 32
),
@@ -951,28 +953,44 @@ BEGIN
END IF;
-- Only applied (outcome_code = 1) and no-op (outcome_code = 3) lifecycle
-- receipts require a paired audit event. A denied lifecycle receipt
-- (outcome_code = 2) commits without one; fabricating a transition event
-- would falsely record that the lifecycle change occurred.
IF receipt.outcome_code NOT IN (1, 3) THEN
RETURN NULL;
END IF;
-- receipts require exactly one paired success-transition event. A denied
-- lifecycle receipt (outcome_code = 2) requires zero events of the mapped
-- transition kind: a success-transition event would falsely record that the
-- denied transition occurred, creating contradictory durable ledger facts.
-- Other outcome codes (4, 5) are not core lifecycle outcomes; skip.
IF receipt.outcome_code IN (1, 3) THEN
SELECT
count(*),
count(*) FILTER (WHERE event_kind = expected_event_kind)
INTO matching_event_count, expected_event_count
FROM authorization_events
WHERE community_id = receipt.community_id
AND operation_id = receipt.operation_id
AND request_fingerprint = receipt.request_fingerprint;
SELECT
count(*),
count(*) FILTER (WHERE event_kind = expected_event_kind)
INTO matching_event_count, expected_event_count
FROM authorization_events
WHERE community_id = receipt.community_id
AND operation_id = receipt.operation_id
AND request_fingerprint = receipt.request_fingerprint;
IF matching_event_count <> 1 OR expected_event_count <> 1 THEN
RAISE EXCEPTION
'lifecycle receipt requires exactly one event kind %, found % total and % expected',
expected_event_kind, matching_event_count, expected_event_count
USING ERRCODE = 'check_violation',
CONSTRAINT = 'authorization_operation_receipt_event_cardinality';
END IF;
ELSIF receipt.outcome_code = 2 THEN
SELECT count(*) FILTER (WHERE event_kind = expected_event_kind)
INTO expected_event_count
FROM authorization_events
WHERE community_id = receipt.community_id
AND operation_id = receipt.operation_id
AND request_fingerprint = receipt.request_fingerprint;
IF matching_event_count <> 1 OR expected_event_count <> 1 THEN
RAISE EXCEPTION
'lifecycle receipt requires exactly one event kind %, found % total and % expected',
expected_event_kind, matching_event_count, expected_event_count
USING ERRCODE = 'check_violation',
CONSTRAINT = 'authorization_operation_receipt_event_cardinality';
IF expected_event_count <> 0 THEN
RAISE EXCEPTION
'denied lifecycle receipt must not have a mapped success-transition event '
'(kind %); found % — contradictory durable facts are not permitted',
expected_event_kind, expected_event_count
USING ERRCODE = 'check_violation',
CONSTRAINT = 'authorization_denied_lifecycle_receipt_no_success_event';
END IF;
END IF;
RETURN NULL;
END;

View File

@@ -2978,8 +2978,10 @@ CREATE TABLE authorization_events (
correlation_id UUID NOT NULL,
attempt_id UUID NOT NULL,
-- Redaction-safe pre-authentication denial identity. Present and non-zero
-- for kind-9 events; NULL for all other event kinds. Binds the event to the
-- exact denial attempt's semantic_fingerprint (intent_digest) for exact replay.
-- for unresolved pre-auth kind-9 events (actor_kind = 4); NULL for
-- authenticated kind-9 events (actor_kind 1-3) and all other event kinds.
-- Binds the event to the exact denial attempt's semantic_fingerprint
-- (intent_digest) for exact replay.
semantic_fingerprint BYTEA CHECK (
semantic_fingerprint IS NULL OR octet_length(semantic_fingerprint) = 32
),
@@ -3730,28 +3732,44 @@ BEGIN
END IF;
-- Only applied (outcome_code = 1) and no-op (outcome_code = 3) lifecycle
-- receipts require a paired audit event. A denied lifecycle receipt
-- (outcome_code = 2) commits without one; fabricating a transition event
-- would falsely record that the lifecycle change occurred.
IF receipt.outcome_code NOT IN (1, 3) THEN
RETURN NULL;
END IF;
-- receipts require exactly one paired success-transition event. A denied
-- lifecycle receipt (outcome_code = 2) requires zero events of the mapped
-- transition kind: a success-transition event would falsely record that the
-- denied transition occurred, creating contradictory durable ledger facts.
-- Other outcome codes (4, 5) are not core lifecycle outcomes; skip.
IF receipt.outcome_code IN (1, 3) THEN
SELECT
count(*),
count(*) FILTER (WHERE event_kind = expected_event_kind)
INTO matching_event_count, expected_event_count
FROM authorization_events
WHERE community_id = receipt.community_id
AND operation_id = receipt.operation_id
AND request_fingerprint = receipt.request_fingerprint;
SELECT
count(*),
count(*) FILTER (WHERE event_kind = expected_event_kind)
INTO matching_event_count, expected_event_count
FROM authorization_events
WHERE community_id = receipt.community_id
AND operation_id = receipt.operation_id
AND request_fingerprint = receipt.request_fingerprint;
IF matching_event_count <> 1 OR expected_event_count <> 1 THEN
RAISE EXCEPTION
'lifecycle receipt requires exactly one event kind %, found % total and % expected',
expected_event_kind, matching_event_count, expected_event_count
USING ERRCODE = 'check_violation',
CONSTRAINT = 'authorization_operation_receipt_event_cardinality';
END IF;
ELSIF receipt.outcome_code = 2 THEN
SELECT count(*) FILTER (WHERE event_kind = expected_event_kind)
INTO expected_event_count
FROM authorization_events
WHERE community_id = receipt.community_id
AND operation_id = receipt.operation_id
AND request_fingerprint = receipt.request_fingerprint;
IF matching_event_count <> 1 OR expected_event_count <> 1 THEN
RAISE EXCEPTION
'lifecycle receipt requires exactly one event kind %, found % total and % expected',
expected_event_kind, matching_event_count, expected_event_count
USING ERRCODE = 'check_violation',
CONSTRAINT = 'authorization_operation_receipt_event_cardinality';
IF expected_event_count <> 0 THEN
RAISE EXCEPTION
'denied lifecycle receipt must not have a mapped success-transition event '
'(kind %); found % — contradictory durable facts are not permitted',
expected_event_kind, expected_event_count
USING ERRCODE = 'check_violation',
CONSTRAINT = 'authorization_denied_lifecycle_receipt_no_success_event';
END IF;
END IF;
RETURN NULL;
END;