Skip to content

Implement durable admission assessments (issue #159) - #182

Open
ezutfen wants to merge 2 commits into
mainfrom
claude/issue-159-5qfoea
Open

Implement durable admission assessments (issue #159)#182
ezutfen wants to merge 2 commits into
mainfrom
claude/issue-159-5qfoea

Conversation

@ezutfen

@ezutfen ezutfen commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements issue #159: durable, inspectable, versioned admission decisions. Every promotion/admission decision made by current Path A policy is now recorded as an append-only admission_assessments row plus a mutable one-row admission_assessment_current projection, enabling post-facto audit and analysis of what policy decided, from which inputs, under which policy identity, and what must happen next.

This is not a new promotion policy. The existing assess_promotion_candidate() and auto_promote_proposed_memories() remain the sole production authority. Capture is gated by ENGRAM_ADMISSION_ASSESSMENT_CAPTURE_ENABLED (default false), so with capture disabled, production behavior and audit JSON are byte-for-byte unchanged.

Key Changes

  • New module engram/admission_assessment.py: Core decision contract with outcome/action classification, digest/hash computation, and database persistence logic. Defines the closed vocabulary for outcomes, next actions, reason codes, and blocker mappings.

  • Database schema (migration 038): Adds admission_assessments (immutable history) and admission_assessment_current (mutable projection) tables with tenant isolation via RLS, structural constraints preventing dishonest decisions, and a trigger enforcing immutability.

  • Integration with promotion flow: Modified engram/promotion.py to capture lane qualification details and invoke assessment recording when capture is enabled. Recomputes lane facts needed for audit without changing any policy thresholds or rules.

  • API routes (engram/api/routes/admission_assessments.py): New endpoints for viewing current/historical assessments and triggering reevaluation. Implements two authority tiers: basic readers see safe summaries; review-scoped users see full normalized inputs and evidence references.

  • Legacy import (engram/admission_backfill.py): Bounded, restartable, idempotent snapshot import of currently observable state for pre-ENG-PROMOTION-003D — Persist promotion assessments and expose next-action state #159 items. Never reconstructs historical policy; uses legacy_import mode with unknown outcomes and unavailable_legacy recheck status.

  • Comprehensive test coverage:

    • test_admission_assessment_unit.py: Pure contract tests (outcome precedence, next-action mapping, digest determinism)
    • test_admission_assessments_postgres.py: Real PostgreSQL behavior (atomicity, concurrency, staleness, policy changes, shadow previews)
    • test_admission_assessments_migration.py: Schema, RLS, privilege, and immutability proofs
    • test_admission_assessment_api.py: API contract and authorization
  • Documentation: ADR-159 explaining the decision, policy identity, digest contents, and invariants.

  • Schema and validation: JSON Schema for the decision envelope (used for RFC 8785 canonical hashing) and Pydantic models for API responses.

Notable Implementation Details

  • Decision hash: SHA-256 of RFC 8785 (JCS) canonical JSON, deterministic across runtimes and time. Identifies a decision over inputs and policy, not one execution of it.

  • Policy identity: Single production profile (path_a_compat / path-a-compat-v1) covering both legacy and evidence lanes. Digest includes thresholds, lane rules, accepted receipt versions, and kind eligibility—but excludes timestamps and invocation IDs.

  • Outcome precedence: Mutation always outranks blockers; shadow mode never admits; stale projections cannot displace current ones; unknown is never coerced to insufficient_evidence.

  • Atomic mutation: When capture is enabled, a proposed -> active mutation fails closed if its assessment, linked audit event, and projection cannot commit atomically.

  • Evidence references: ENG-CLASSIFY-003 — Separate retention value from epistemic confidence and support versioned reassessment #157 memory_assessments are recorded as diagnostic references only (max 8 per decision); their epistemic dimensions carry no admission authority in v1.

  • Conflict recheck: Distinguishes clear, blocked, not_run, not_run_preview (shadow), and unavailable_legacy (pre-ENG-PROMOTION-003D — Persist promotion assessments and expose next-action state #159 import).

https://claude.ai/code/session_01Q9TdJoC37r48XLwhUjYGqf

Make every promotion/admission decision a durable, inspectable, versioned
artifact instead of a transient calculation reconstructed from mutable item
columns and job state.

This persists the decision current Path A policy produces; it does not
replace that policy. assess_promotion_candidate() and
auto_promote_proposed_memories() remain the sole production authority, and no
threshold, weight, cooling period, lane rule or blocker code changes. #157
memory_assessments are recorded as diagnostic references only and never enter
the input or policy digests.

Schema (migration 038, additive, no row-per-item reconstruction):
- append-only admission_assessments with FORCE RLS, a no-rewrite trigger and
  revoked UPDATE grant. Reads follow item read eligibility; writes are tenant
  scoped so the worker can record decisions for items it does not author.
- admission_assessment_current, a one-row projection keyed by
  (tenant, item, policy_profile_key), carrying identity plus the precedence
  metadata needed to resolve current vs stale deterministically.
- nullable item_events.admission_assessment_id; historical events stay
  unlinked.

Contract (engram.admission-assessment.v1, ADR + JSON schema + golden vectors):
- one policy profile, path_a_compat / path-a-compat-v1; no #158 semantics.
- decision_hash is SHA-256 over RFC 8785 canonical bytes of a deterministic
  envelope; invocation identity and projection state are excluded, and
  blocker/reason codes are sorted so discovery order cannot change the hash.
- outcome precedence stale > blocked > review_required > cooling >
  insufficient_evidence > unknown, with admitted/would_admit handled
  separately. cooling requires a lane that would otherwise qualify, so it is
  never inferred from an age blocker alone, and unknown is never coerced into
  insufficient evidence.

Integration:
- capture is gated by ENGRAM_ADMISSION_ASSESSMENT_CAPTURE_ENABLED (default
  false); disabled, promotion behavior and audit JSON are unchanged.
- enabled, a proposed -> active mutation commits atomically with its
  assessment, linked review_change event and projection, or fails closed.
- the assessment insert is ordered after the guarded mutation so a lost race
  cannot produce a false admitted row; the loser appends a truthful
  non-mutating result instead.
- policy/config is revalidated under the item lock: a change mid-evaluation
  makes the pre-change result stale history and forces reevaluation before
  any mutation. A stale decision never becomes current.
- dry-run previews are written after the pass rolls back, as shadow rows that
  can never mutate state or be projected.

Also adds current/history/detail/reevaluate API routes, safe summaries on
promotion readiness and the review queue with the scoped filters, and a
bounded, restartable, idempotent legacy import that fabricates no historical
evaluation, evidence or conflict fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9TdJoC37r48XLwhUjYGqf
1. Evaluated-input identity was on the wrong side of lifecycle mutations.

The guarded proposed -> active UPDATE synchronizes back onto the live ORM
object, so the decision was hashed against state its own mutation produced: an
admitted decision recorded decision_inputs.review_status='active', asserting
that policy evaluated an already-active item. The conflict-recheck path had
the mirror problem — it wrote the blocked decision, then mutated the conflict
fields that participate in the input digest, so the decision was stale against
its own effect the instant it committed.

The evaluated state is now snapshotted before any mutation in the pass and the
hashing path consumes that snapshot, never a live object. A new nullable
resulting_state_digest records what the decision's own authorized mutation was
expected to produce, and freshness resolves against it when present. Both
claims are hashed, separately. A shadow preview can never carry one; the
database enforces that alongside the outcome restriction. The conflict marking
moved ahead of its capture (extracted to _mark_promotion_conflict, audit JSON
unchanged) so the blocked decision can declare what its recheck changed.

2. Canonical promotion.evaluate retry was not idempotent.

UNIQUE (tenant_id, evaluation_id) existed but nothing reused the bound row, so
a job whose decision committed before its worker died would collide and
dead-letter work that was already durably complete. insert_assessment now
resolves a supplied identity back to its bound decision first. The identity
also no longer binds the wrong row: a superseded pre-lock result is recorded
with no evaluation_id, and the reevaluation that replaces it claims it.

3. Linked admitted assessments could break item deletion.

linked_item_event_id used ON DELETE SET NULL, whose referential action attempts
an UPDATE on immutable history — refused by the no-rewrite trigger. Deleting
the linked event directly failed outright, and the parent-item cascade survived
only by incidental FK ordering. It is now a deferred NO ACTION constraint: the
parent cascade removes event and decision together, while destroying the event
alone still violates it. History is never rewritten either way.

4. Review-queue filters ran after the limit.

Outcome, blocker, next-action and due-before were applied in Python to an
already-limited page, so a matching item past the limit produced a false
"nothing matches". Those are stored facts and now become SQL predicates over a
correlated EXISTS; missing is a SQL predicate too. Only the computed
current/stale state needs the digest comparison, and it walks the narrowed
queue in bounded keyset batches to an explicit cap instead of stopping at the
first window.

Migration 038 is convergent, so a database created by its earlier shape picks
up the new column and constraint in place. Golden vectors, JSON schema and ADR
updated for the envelope change. Adds regressions for every fix, including the
retry crash window, the conflict-blocked-stays-current case, the linked-pair
cascade, and a queue match sitting past the first unfiltered window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9TdJoC37r48XLwhUjYGqf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants