Skip to content

fix(server): release declared-key ownership on delete and null (ARN-238) - #398

Draft
rita-aga wants to merge 3 commits into
mainfrom
grok/arn-238-stale-key-ownership
Draft

fix(server): release declared-key ownership on delete and null (ARN-238)#398
rita-aga wants to merge 3 commits into
mainfrom
grok/arn-238-stale-key-ownership

Conversation

@rita-aga

@rita-aga rita-aga commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Full fix for ARN-238: declared-key ownership no longer stays stale after an entity is deleted or a declared key is fully nulled.

ADR-0172 (unique vs competitor ADR-0159): EntityKeyRow.key_hash == "" is a RELEASE marker co-committed with the journal.

Root cause

ADR-0153 co-commit derived key rows only from post-transition fields. Tombstone writes still held key values in fields, so the dead entity re-claimed the hash. Fully nulled keys emitted no row, so the store never deleted the prior hash.

Fix

  1. Actor — emit one row per declared key every write: real hash when living+resolvable, empty RELEASE when tombstoned (status/to_status == Deleted) or key unresolvable.
  2. Stores (postgres + sim) — uniqueness/insert skip empty hashes; per-key-name delete always runs.
  3. Backfill — heal legacy stale rows: tombstones release all keys; living entities release unresolvable keys (pre-watermark). EntityLoadOutcome::Tombstoned added.

Commits (RED → GREEN)

  1. 3aba7ae6 — RED: failing DST for delete-release + backfill heal (delete + null legs)
  2. ab875229 — GREEN: release markers + stores + backfill + ADR-0172

Tests

cargo test -p temper-server --test dst_entity_key_index
# 6/6 pass (3 new ARN-238 + 3 regression)

Residuals

  • Composite append_batch still carries no key rows (pre-existing ADR-0153 gap).
  • Already-watermarked types need manual re-backfill for pre-existing stale rows; live path prevents new staleness.

Linear

ARN-238

Greptile Summary

This PR fixes ARN-238 by introducing an empty-string RELEASE marker (EntityKeyRow.key_hash == \"\") that causes stores to drop an entity's declared-key ownership atomically with the journal append, preventing tombstoned or null-keyed entities from holding stale rows forever and blocking new claimants.

  • Actor (live path): emits one EntityKeyRow per declared key on every write — a real hash when the entity is living and the key resolves, an empty RELEASE marker when the write tombstones the entity (state.status == \"Deleted\" or event.to_status == \"Deleted\") or when key components are all null/absent.
  • Stores (Postgres + Sim): uniqueness pre-checks and inserts skip empty hashes; the per-key-name DELETE always runs, so each release marker atomically frees the prior row in the same transaction as the journal write.
  • Backfill heal: a new EntityLoadOutcome::Tombstoned variant routes deleted entities to a release-only pass; living entities with unresolvable keys emit mixed real-hash + RELEASE rows; three new DST tests cover all three legs.

Confidence Score: 5/5

The write path, both stores, and the backfill all handle release markers consistently; correctness is well-covered by the three new DST tests.

The RELEASE marker protocol is applied uniformly across the live write path (actor), both store implementations, and the backfill; no code path can silently re-claim a key after tombstoning or nulling. The one comment left is a logging/performance note on the backfill Tombstoned arm and does not affect the correctness of key ownership.

crates/temper-server/src/state/projection_backfill/key_index.rs — the Tombstoned arm early-out and healed counter, though no correctness issue.

Important Files Changed

Filename Overview
crates/temper-server/src/entity_actor/actor.rs Tombstone detection via state.status and event.to_status correctly emits RELEASE markers for all declared keys; null-key RELEASE is handled by the unwrap_or_default path; logic is sound
crates/temper-server/src/state/projection_backfill/key_index.rs Tombstoned backfill arm correctly issues release markers; healed counter and DB round-trips are slightly inflated for tombstoned entities that have no stale rows (see comment)
crates/temper-store-postgres/src/store.rs Release-marker handling correct: uniqueness pre-check skips empty hashes, per-key-name DELETE always runs, INSERT skipped for empty hashes; backfill path mirrors same pattern
crates/temper-store-sim/src/lib.rs Sim store mirrors Postgres release-marker semantics on all three paths; retain-then-continue pattern correctly releases stale rows without inserting empty-hash entries
crates/temper-server/src/state/projection_backfill.rs EntityLoadOutcome::Tombstoned variant correctly splits deleted-entity handling from phantom Skip; vector_index updated to treat Tombstoned identically to Skip
crates/temper-server/src/state/projection_backfill/vector_index.rs Single-line change adds Tombstoned to the vector purge arm; semantically correct - deleted entities must not rank in vector search
crates/temper-server/tests/dst_entity_key_index.rs Three new DST tests cover delete-release live path, legacy-stale backfill healing for tombstoned entities, and living-entity null-key backfill healing; all three legs of the fix are exercised
crates/temper-runtime/src/persistence/mod.rs Adds doc comment to EntityKeyRow.key_hash explaining the empty-string RELEASE marker contract; no code changes
docs/adrs/0172-declared-key-release.md ADR is complete and accurate: documents the RELEASE marker protocol, both store paths, backfill healing, composite-write residual, rollback policy, and alternatives considered

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Entity write (actor.rs)"] --> B{"tombstoned?\nstate.status==Deleted\nOR event.to_status==Deleted"}
    B -- yes --> C["key_hash = RELEASE marker (empty)"]
    B -- no --> D{"key components resolvable?"}
    D -- yes --> E["key_hash = canonical hash"]
    D -- no --> C
    C --> F["push EntityKeyRow with key_hash=empty"]
    E --> F
    F --> G["append_with_index_rows"]
    G --> H{"per key_row in store"}
    H --> I["DELETE entity prior row for key_name"]
    I --> J{"key_hash empty?"}
    J -- yes RELEASE --> K["done - row deleted, nothing inserted"]
    J -- no --> L["INSERT key_name, key_hash, entity_id"]
    M["Boot backfill (key_index.rs)"] --> N["load entity state"]
    N --> O{"EntityLoadOutcome?"}
    O -- Tombstoned --> P["emit RELEASE markers for all declared keys"]
    O -- "Fields: already_keyed AND all keys resolve" --> Q["skip - already clean"]
    O -- "Fields: any key unresolvable" --> R["emit RELEASE for null keys, real hash for resolvable"]
    O -- Skip --> S["count skipped"]
    P --> T["backfill_entity_keys - DELETE stale rows"]
    R --> T
    T --> U["healed or newly_keyed counter"]
    U --> V{"failed == 0?"}
    V -- yes --> W["set watermark"]
    V -- no --> X["log warning - resume next boot"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["Entity write (actor.rs)"] --> B{"tombstoned?\nstate.status==Deleted\nOR event.to_status==Deleted"}
    B -- yes --> C["key_hash = RELEASE marker (empty)"]
    B -- no --> D{"key components resolvable?"}
    D -- yes --> E["key_hash = canonical hash"]
    D -- no --> C
    C --> F["push EntityKeyRow with key_hash=empty"]
    E --> F
    F --> G["append_with_index_rows"]
    G --> H{"per key_row in store"}
    H --> I["DELETE entity prior row for key_name"]
    I --> J{"key_hash empty?"}
    J -- yes RELEASE --> K["done - row deleted, nothing inserted"]
    J -- no --> L["INSERT key_name, key_hash, entity_id"]
    M["Boot backfill (key_index.rs)"] --> N["load entity state"]
    N --> O{"EntityLoadOutcome?"}
    O -- Tombstoned --> P["emit RELEASE markers for all declared keys"]
    O -- "Fields: already_keyed AND all keys resolve" --> Q["skip - already clean"]
    O -- "Fields: any key unresolvable" --> R["emit RELEASE for null keys, real hash for resolvable"]
    O -- Skip --> S["count skipped"]
    P --> T["backfill_entity_keys - DELETE stale rows"]
    R --> T
    T --> U["healed or newly_keyed counter"]
    U --> V{"failed == 0?"}
    V -- yes --> W["set watermark"]
    V -- no --> X["log warning - resume next boot"]
Loading

Reviews (2): Last reviewed commit: "fix(server): count newly_keyed only for ..." | Re-trigger Greptile

rita-aga added 2 commits July 14, 2026 11:35
…te (ARN-238)

RED: assert delete releases key ownership (reclaim by a new entity), and that
boot key-index backfill heals legacy stale rows for tombstoned entities and
living entities whose declared keys no longer resolve.
ADR-0172: EntityKeyRow.key_hash == \"\" is a RELEASE marker. The entity actor
emits one row per declared key on every write — a real hash when living and
resolvable, a release marker when the write tombstones the entity or the key
no longer resolves. Postgres and sim skip empty hashes on uniqueness/insert
and always drop the entity's prior row for that key_name. Key backfill heals
legacy stale rows for tombstones and fully-nulled keys (pre-watermark).

Verified: cargo test -p temper-server --test dst_entity_key_index → 6/6 pass.
@rita-aga

Copy link
Copy Markdown
Collaborator Author

Grok independent review — PR #398 (ARN-238 / ADR-0172)

Scope: Release declared-key ownership on delete and null via empty-hash RELEASE markers; heal legacy stale rows in key-index backfill.

What was done well

  • Root cause is fixed at the write path, not papered over at read time: tombstones no longer re-claim keys from still-populated fields (state.status == "Deleted" || event.to_status == "Deleted"), matching the Delete-arm persist-before-mutate ordering called out in the ADR.
  • One row per declared key on every keyed write makes null/absent components release without a separate trait flag — better than a full reconcile_keys signature churn, and keeps per-key granularity when only one of several keys goes null.
  • Store handling is consistent across Postgres and sim: uniqueness + insert skip empty hashes; per-key_name delete still runs so release is atomic with the journal append.
  • Backfill healing is careful: Tombstoned vs Skip, membership_known so “empty set” does not mean “known unindexed,” and living fully-resolvable already-keyed entities still skip re-writes. Metrics split newly_keyed vs healed.
  • RED→GREEN DST coverage: delete releases + reclaim, legacy tombstone re-claim heal, living unresolvable-key heal. ADR residual (watermarked types, composite append_batch) is explicit.

Findings

Important (should fix / track before relying on production heal)

  1. Already-watermarked types do not heal
    Live release stops new staleness; pre-existing rows on types that already completed key-index watermark remain until a manual re-backfill. ADR documents this. For any deploy that already watermarked keyed types, ship an explicit one-shot re-backfill or force-rekey ops path — otherwise deleted entities that predate this PR can still block reclaim.

  2. Composite append_batch still neither claims nor releases keys
    Documented ADR residual / ADR-0153 gap. An actor-keyed entity tombstoned only via composite sub-writes can leave stale ownership until pre-watermark heal. Track as a linked follow-up if not already (do not treat backfill as the long-term fix for that path).

Suggestions (nice to have)

  1. No live (non-backfill) DST for “key components nulled on a living entity”
    Actor path emits empty-hash rows for unresolvable keys; backfill covers the legacy case. A co-commit DST that mutates fields to null then asserts reclaim would pin the live null leg the way delete is pinned.

  2. Empty-string RELEASE sentinel
    Documented and fine if canonical_key_hash never returns "". A debug_assert that non-release hashes are non-empty at emit time would make the invariant local and greppable.

  3. PR title is bare fix(server) — prefer something that names release-on-delete for bisect/history.

Plan alignment

Matches ADR-0172: release markers, store skip-on-claim, backfill heal, non-goals honored. Residual risks are disclosed rather than hidden.

Verdict

No correctness gaps in the primary live delete path relative to the stated bug; residuals are operational/follow-up, not silent wrongness on the new write path.

Verdict: PASS

@rita-aga

Copy link
Copy Markdown
Collaborator Author

@greptile review

@rita-aga

Copy link
Copy Markdown
Collaborator Author

ARENA SHIPPABLE · Grok · 2026-07-14 11:57 PDT

PR: #398
HEAD: ab875229b935 · branch grok/arn-238-stale-key-ownership
Linear: ARN-238 · ADR: 0172
Merge: nothing (arena rules)

Checklist

Gate Evidence
RED→GREEN on branch history
Independent same-model review Verdict: PASS posted on PR
Greptile requested after PASS
Local tests targeted suite green before push
CI GitHub Actions on head

Summary

declared-key release

Comment thread crates/temper-server/src/state/projection_backfill/key_index.rs
Comment on lines +226 to +254
EntityLoadOutcome::Tombstoned => {
// ARN-238 healing pass: a deleted entity must not keep its
// declared keys. Emit a release marker per declared key so
// rows written before release-on-delete are purged.
let release_rows: Vec<temper_runtime::persistence::EntityKeyRow> = keys
.iter()
.map(|key| temper_runtime::persistence::EntityKeyRow {
key_name: key.name.clone(),
key_hash: String::new(),
})
.collect();
match store
.backfill_entity_keys(
tenant.as_str(),
entity_type,
entity_id,
&release_rows,
)
.await
{
Ok(()) => healed += 1,
Err(e) => {
failed += 1;
tracing::warn!(
error = %e, entity_type = %entity_type, entity_id = %entity_id,
"key index backfill: tombstone release failed"
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Watermarked types are not re-healed; stale rows persist indefinitely

The Tombstoned healing arm is only reachable during the backfill, which is skipped for any entity type that already carries a watermark. For production deployments where a keyed type's watermark was written before this fix landed, every entity deleted in that window still holds its stale entity_key_index row. New entities trying to claim the same key values will continue to receive uniqueness-violation rejections forever — the exact "durable damage" ARN-238 describes. Live release prevents new staleness going forward, but the pre-existing stale rows are never cleared unless the watermark is manually dropped and the backfill re-runs. Consider whether the fix plan needs a one-shot migration or a safe watermark-reset mechanism before this merges.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/state/projection_backfill/key_index.rs
Line: 226-254

Comment:
**Watermarked types are not re-healed; stale rows persist indefinitely**

The `Tombstoned` healing arm is only reachable during the backfill, which is skipped for any entity type that already carries a watermark. For production deployments where a keyed type's watermark was written before this fix landed, every entity deleted in that window still holds its stale `entity_key_index` row. New entities trying to claim the same key values will continue to receive uniqueness-violation rejections forever — the exact "durable damage" ARN-238 describes. Live release prevents new staleness going forward, but the pre-existing stale rows are never cleared unless the watermark is manually dropped and the backfill re-runs. Consider whether the fix plan needs a one-shot migration or a safe watermark-reset mechanism before this merges.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

@rita-aga rita-aga changed the title fix(server) fix(server): release declared-key ownership on delete and null (ARN-238) Jul 14, 2026
@rita-aga

Copy link
Copy Markdown
Collaborator Author

@greptile review

@rita-aga

Copy link
Copy Markdown
Collaborator Author

Addressed: newly_keyed only increments on first-time claims (!was_already_keyed). Residual P1 (post-watermark tombstone heal): healing still runs only during pre-watermark backfill; watermarked types rely on release-on-delete for new deletes. Explicit re-heal of watermarked tombstones deferred.

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.

1 participant