feat: persist trustworthy-transition continuity v0.1#770
Conversation
📝 WalkthroughWalkthroughAdds a "trustworthy-transition" continuity adapter to the ls-continuity-store package: new snapshot data model, evidence-digest computation, envelope creation/persistence, resume-decision evaluation, snapshot-chain consistency assessment, a fixture-driven test suite, a runnable transition demo, a new CI workflow, and expanded README/profile documentation. ChangesTrustworthy-transition continuity adapter
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant createTransitionSnapshotEnvelope
participant computeEvidenceSetDigest
participant ContinuityStore
participant evaluateTransitionResume
Caller->>computeEvidenceSetDigest: transition_id, subject_id, refs, observations
computeEvidenceSetDigest-->>Caller: evidence_set_digest
Caller->>createTransitionSnapshotEnvelope: snapshot input + createdAt
createTransitionSnapshotEnvelope-->>Caller: continuity envelope
Caller->>ContinuityStore: persist(envelope)
ContinuityStore-->>Caller: stored snapshot
Caller->>ContinuityStore: reload snapshot (after restart)
ContinuityStore-->>Caller: stored snapshot payload
Caller->>evaluateTransitionResume: stored snapshot + resume request
evaluateTransitionResume-->>Caller: allow/posture/reason decision
Possibly related issues
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
TRI-AGENT LIVE TRAIL RUN 001 Repository: safal207/LS Review this exact immutable HEAD only. Until all review attempts finish:
Review independently. Focus:
|
|
/agentic_review |
|
@coderabbitai full review |
✅ Action performedFull review finished. |
|
@codex review for correctness, security regressions, unsafe continuation paths, and missing adversarial tests. Review the current HEAD independently. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0ed661304e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| binding_digest: bindingDigest, | ||
| record_refs: recordRefs | ||
| }), | ||
| dimensions: { ...input.dimensions }, |
There was a problem hiding this comment.
Validate transition dimensions before granting resumes
At this point the API persists whatever runtime values are in input.dimensions, while verification_receipt payloads are only validated as z.record(z.unknown()). A JS caller can persist authority: "STALE", execution: "NOT_OBSERVED", response_integrity: "VERIFIED", and causal_validity: "VALID"; evaluateTransitionResume has no default fail-closed branch for the unknown authority/causal/integrity values and falls through to CONTINUE_SIDE_EFFECT when evidence/context match. That turns malformed or adversarial snapshots into live permission, so the enum set needs runtime validation here or in verifyStoredSnapshot.
Useful? React with 👍 / 👎.
|
|
||
| if ( | ||
| TERMINAL_AUTHORITY.has(previous.payload.dimensions.authority) && | ||
| current.payload.dimensions.authority === "VALID" |
There was a problem hiding this comment.
Reject multi-hop reopening of terminal authority
Because this guard only rejects a direct terminal-authority -> VALID transition, a same-authorization chain such as DENIED -> PENDING -> VALID passes pairwise assessSnapshotChain checks: the first hop is not VALID, and the second hop no longer has a terminal previous state. After that, evaluateTransitionResume can allow resume_side_effect under the original denied/expired/consumed authorization. Terminal authority needs to remain sticky until an explicit new authorization/ref is present, not only on direct transitions to VALID.
Useful? React with 👍 / 👎.
Code Review by Qodo
Context used 1. Resume ignores snapshot chain
|
| export function evaluateTransitionResume( | ||
| snapshot: StoredContinuityObject<TransitionContinuitySnapshotPayload>, | ||
| request: TransitionResumeRequest | ||
| ): TransitionResumeDecision { | ||
| verifyStoredSnapshot(snapshot); | ||
| const payload = snapshot.payload; | ||
|
|
||
| if (request.transition_id !== payload.transition_id) { | ||
| return decision(snapshot, false, "BLOCKED", "TRANSITION_MISMATCH"); | ||
| } | ||
| if (request.subject_id !== payload.subject_id) { | ||
| return decision(snapshot, false, "BLOCKED", "SUBJECT_MISMATCH"); | ||
| } | ||
| if ( | ||
| request.action_identity_digest !== payload.action_identity_digest || | ||
| request.binding_digest !== payload.binding_digest | ||
| ) { | ||
| return decision(snapshot, false, "BLOCKED", "ACTION_BINDING_MISMATCH"); | ||
| } | ||
|
|
||
| if (request.operation === "report_only") { | ||
| const checks: string[] = []; | ||
| if (request.current_evidence_set_digest !== payload.evidence_set_digest) { | ||
| checks.push("evidence_set_changed_since_snapshot"); | ||
| } | ||
| if (request.current_context_digest !== payload.context_digest) { | ||
| checks.push("context_changed_since_snapshot"); | ||
| } | ||
| return decision( | ||
| snapshot, | ||
| true, | ||
| "REPORT_ONLY", | ||
| "HISTORICAL_REPORT_ONLY", | ||
| checks | ||
| ); | ||
| } | ||
|
|
||
| if (request.current_evidence_set_digest !== payload.evidence_set_digest) { | ||
| return decision(snapshot, false, "REVALIDATE", "EVIDENCE_DRIFT", [ | ||
| "rebuild_transition_snapshot" | ||
| ]); | ||
| } | ||
| if (request.current_context_digest !== payload.context_digest) { | ||
| return decision(snapshot, false, "REVALIDATE", "CONTEXT_DRIFT", [ | ||
| "revalidate_runtime_context" | ||
| ]); | ||
| } | ||
|
|
||
| if (payload.dimensions.causal_validity === "INVALID") { | ||
| return decision(snapshot, false, "BLOCKED", "CAUSAL_LINEAGE_INVALID", [ | ||
| "repair_causal_lineage" | ||
| ]); | ||
| } | ||
| if ( | ||
| payload.dimensions.causal_validity === "UNKNOWN" || | ||
| payload.dimensions.causal_validity === "NOT_EVALUATED" | ||
| ) { | ||
| return decision( | ||
| snapshot, | ||
| false, | ||
| "REVALIDATE", | ||
| "CAUSAL_LINEAGE_NOT_EVALUATED", | ||
| ["evaluate_causal_lineage"] | ||
| ); | ||
| } | ||
|
|
||
| if (request.operation === "remediate_response") { | ||
| if ( | ||
| payload.dimensions.response_integrity === "FAILED" || | ||
| payload.dimensions.response_integrity === "PARTIAL" | ||
| ) { | ||
| return decision( | ||
| snapshot, | ||
| true, | ||
| "REMEDIATE_RESPONSE", | ||
| "RESPONSE_REMEDIATION_ONLY" | ||
| ); | ||
| } | ||
| return decision(snapshot, false, "BLOCKED", "UNKNOWN_STATE", [ | ||
| "no_response_remediation_required" | ||
| ]); | ||
| } | ||
|
|
||
| if ( | ||
| payload.authority_expires_at !== null && | ||
| parseTime(request.now, "now") >= parseTime(payload.authority_expires_at, "authority_expires_at") | ||
| ) { | ||
| return decision(snapshot, false, "BLOCKED", "AUTHORITY_EXPIRED", [ | ||
| "obtain_fresh_authorization" | ||
| ]); | ||
| } | ||
|
|
||
| switch (payload.dimensions.authority) { | ||
| case "DENIED": | ||
| return decision(snapshot, false, "BLOCKED", "AUTHORITY_DENIED"); | ||
| case "PENDING": | ||
| return decision(snapshot, false, "BLOCKED", "AUTHORITY_PENDING", [ | ||
| "resolve_pending_approval" | ||
| ]); | ||
| case "EXPIRED": | ||
| case "EXPIRED_AT_REPORT": | ||
| return decision(snapshot, false, "BLOCKED", "AUTHORITY_EXPIRED", [ | ||
| "obtain_fresh_authorization" | ||
| ]); | ||
| case "CONSUMED": | ||
| return decision(snapshot, false, "ALREADY_CONSUMED", "AUTHORITY_CONSUMED"); | ||
| case "REVALIDATION_REQUIRED": | ||
| return decision( | ||
| snapshot, | ||
| false, | ||
| "REVALIDATE", | ||
| "AUTHORITY_REVALIDATION_REQUIRED", | ||
| ["revalidate_authority"] | ||
| ); | ||
| case "NOT_EVALUATED": | ||
| case "UNKNOWN": | ||
| return decision( | ||
| snapshot, | ||
| false, | ||
| "REVALIDATE", | ||
| "AUTHORITY_NOT_EVALUATED", | ||
| ["evaluate_authority"] | ||
| ); | ||
| case "VALID": | ||
| break; | ||
| } | ||
|
|
||
| if ( | ||
| payload.side_effect_committed || | ||
| payload.dimensions.execution === "OBSERVED_EXECUTED" | ||
| ) { | ||
| return decision( | ||
| snapshot, | ||
| false, | ||
| "ALREADY_CONSUMED", | ||
| payload.side_effect_committed | ||
| ? "SIDE_EFFECT_ALREADY_COMMITTED" | ||
| : "EXECUTION_ALREADY_OBSERVED" | ||
| ); | ||
| } | ||
|
|
||
| if (payload.dimensions.execution === "OBSERVED_BLOCKED") { | ||
| return decision(snapshot, false, "BLOCKED", "EXECUTION_BLOCKED", [ | ||
| "obtain_new_authorization" | ||
| ]); | ||
| } | ||
|
|
||
| if (payload.dimensions.response_integrity === "FAILED") { | ||
| return decision( | ||
| snapshot, | ||
| false, | ||
| "REMEDIATE_RESPONSE", | ||
| "RESPONSE_INTEGRITY_FAILED", | ||
| ["remediate_response_before_continuation"] | ||
| ); | ||
| } | ||
| if (payload.dimensions.response_integrity === "PARTIAL") { | ||
| return decision( | ||
| snapshot, | ||
| false, | ||
| "REVALIDATE", | ||
| "RESPONSE_INTEGRITY_PARTIAL", | ||
| ["resolve_unverifiable_claims"] | ||
| ); | ||
| } | ||
|
|
||
| if (payload.dimensions.execution === "OBSERVED_ERRORED") { | ||
| if ( | ||
| request.operation === "retry_side_effect" && | ||
| payload.retry.retryable_after_error && | ||
| payload.retry.idempotency_key !== null && | ||
| request.idempotency_key === payload.retry.idempotency_key | ||
| ) { | ||
| return decision(snapshot, true, "RETRY_SIDE_EFFECT", "OK"); | ||
| } | ||
| return decision(snapshot, false, "REVALIDATE", "RETRY_NOT_PROVEN_SAFE", [ | ||
| "prove_idempotent_retry" | ||
| ]); | ||
| } | ||
|
|
||
| if ( | ||
| request.operation === "resume_side_effect" && | ||
| payload.dimensions.execution === "NOT_OBSERVED" | ||
| ) { | ||
| return decision(snapshot, true, "CONTINUE_SIDE_EFFECT", "OK"); | ||
| } | ||
|
|
||
| return decision(snapshot, false, "BLOCKED", "UNKNOWN_STATE"); | ||
| } |
There was a problem hiding this comment.
1. Resume ignores snapshot chain 📎 Requirement gap ⛨ Security
evaluateTransitionResume() makes allow/deny decisions without validating that the provided snapshot is the latest, forward-only snapshot in the previous_ref chain. This allows a caller to evaluate an older, more-permissive snapshot and potentially resume when a newer snapshot should have blocked continuation.
Agent Prompt
## Issue description
`evaluateTransitionResume()` does not enforce `previous_ref` chain monotonicity, so callers can evaluate stale snapshots and bypass forward-only continuity rules.
## Issue Context
The module defines `assessSnapshotChain(previous, current)`, but resume decisions are made without requiring a chain check or ensuring the evaluated snapshot is the latest/most-restrictive snapshot.
## Fix Focus Areas
- packages/ls-continuity-store/src/trustworthy-transition.ts[344-532]
- packages/ls-continuity-store/src/trustworthy-transition.ts[542-606]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| { | ||
| "fixture_version": "0.1", | ||
| "profile": "org.ls.trustworthy-transition-continuity.v0.1", | ||
| "cases": [ | ||
| { | ||
| "case_id": "active_unobserved_resume", | ||
| "snapshot": { | ||
| "transition_id": "transition-001", | ||
| "subject_id": "agent:deploy", | ||
| "action_identity_digest": "$REF:action-001", | ||
| "binding_digest": "$REF:binding-001", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-001", | ||
| "observation_refs": [], | ||
| "response_integrity_ref": null, | ||
| "causal_audit_ref": "$REF:causal-001" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "VALID", | ||
| "execution": "NOT_OBSERVED", | ||
| "response_integrity": "NOT_EVALUATED", | ||
| "causal_validity": "VALID" | ||
| }, | ||
| "side_effect_committed": false, | ||
| "authority_expires_at": "2030-01-01T01:00:00.000Z", | ||
| "context_digest": "$REF:context-001" | ||
| }, | ||
| "request": { | ||
| "operation": "resume_side_effect", | ||
| "now": "2030-01-01T00:10:00.000Z" | ||
| }, | ||
| "expected": { | ||
| "allowed": true, | ||
| "posture": "CONTINUE_SIDE_EFFECT", | ||
| "reason": "OK" | ||
| } | ||
| }, | ||
| { | ||
| "case_id": "executed_restart_cannot_replay", | ||
| "snapshot": { | ||
| "transition_id": "transition-002", | ||
| "subject_id": "agent:deploy", | ||
| "action_identity_digest": "$REF:action-002", | ||
| "binding_digest": "$REF:binding-002", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-002", | ||
| "observation_refs": ["$REF:observation-002"], | ||
| "response_integrity_ref": "$REF:integrity-002", | ||
| "causal_audit_ref": "$REF:causal-002" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "VALID", | ||
| "execution": "OBSERVED_EXECUTED", | ||
| "response_integrity": "VERIFIED", | ||
| "causal_validity": "VALID" | ||
| }, | ||
| "side_effect_committed": true, | ||
| "authority_expires_at": "2030-01-01T01:00:00.000Z", | ||
| "context_digest": "$REF:context-002" | ||
| }, | ||
| "request": { | ||
| "operation": "retry_side_effect", | ||
| "now": "2030-01-01T00:10:00.000Z" | ||
| }, | ||
| "expected": { | ||
| "allowed": false, | ||
| "posture": "ALREADY_CONSUMED", | ||
| "reason": "SIDE_EFFECT_ALREADY_COMMITTED" | ||
| } | ||
| }, | ||
| { | ||
| "case_id": "expired_snapshot_cannot_resume", | ||
| "snapshot": { | ||
| "transition_id": "transition-003", | ||
| "subject_id": "agent:deploy", | ||
| "action_identity_digest": "$REF:action-003", | ||
| "binding_digest": "$REF:binding-003", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-003", | ||
| "observation_refs": [], | ||
| "response_integrity_ref": null, | ||
| "causal_audit_ref": "$REF:causal-003" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "VALID", | ||
| "execution": "NOT_OBSERVED", | ||
| "response_integrity": "NOT_EVALUATED", | ||
| "causal_validity": "VALID" | ||
| }, | ||
| "side_effect_committed": false, | ||
| "authority_expires_at": "2030-01-01T00:05:00.000Z", | ||
| "context_digest": "$REF:context-003" | ||
| }, | ||
| "request": { | ||
| "operation": "resume_side_effect", | ||
| "now": "2030-01-01T00:10:00.000Z" | ||
| }, | ||
| "expected": { | ||
| "allowed": false, | ||
| "posture": "BLOCKED", | ||
| "reason": "AUTHORITY_EXPIRED" | ||
| } | ||
| }, | ||
| { | ||
| "case_id": "exact_idempotent_retry_after_error", | ||
| "snapshot": { | ||
| "transition_id": "transition-004", | ||
| "subject_id": "agent:deploy", | ||
| "action_identity_digest": "$REF:action-004", | ||
| "binding_digest": "$REF:binding-004", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-004", | ||
| "observation_refs": ["$REF:observation-004"], | ||
| "response_integrity_ref": null, | ||
| "causal_audit_ref": "$REF:causal-004" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "VALID", | ||
| "execution": "OBSERVED_ERRORED", | ||
| "response_integrity": "NOT_EVALUATED", | ||
| "causal_validity": "VALID" | ||
| }, | ||
| "side_effect_committed": false, | ||
| "authority_expires_at": "2030-01-01T01:00:00.000Z", | ||
| "context_digest": "$REF:context-004", | ||
| "retry": { | ||
| "retryable_after_error": true, | ||
| "idempotency_key": "retry-key-004" | ||
| } | ||
| }, | ||
| "request": { | ||
| "operation": "retry_side_effect", | ||
| "now": "2030-01-01T00:10:00.000Z", | ||
| "idempotency_key": "retry-key-004" | ||
| }, | ||
| "expected": { | ||
| "allowed": true, | ||
| "posture": "RETRY_SIDE_EFFECT", | ||
| "reason": "OK" | ||
| } | ||
| }, | ||
| { | ||
| "case_id": "wrong_retry_key_requires_revalidation", | ||
| "snapshot": { | ||
| "transition_id": "transition-005", | ||
| "subject_id": "agent:deploy", | ||
| "action_identity_digest": "$REF:action-005", | ||
| "binding_digest": "$REF:binding-005", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-005", | ||
| "observation_refs": ["$REF:observation-005"], | ||
| "response_integrity_ref": null, | ||
| "causal_audit_ref": "$REF:causal-005" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "VALID", | ||
| "execution": "OBSERVED_ERRORED", | ||
| "response_integrity": "NOT_EVALUATED", | ||
| "causal_validity": "VALID" | ||
| }, | ||
| "side_effect_committed": false, | ||
| "authority_expires_at": "2030-01-01T01:00:00.000Z", | ||
| "context_digest": "$REF:context-005", | ||
| "retry": { | ||
| "retryable_after_error": true, | ||
| "idempotency_key": "retry-key-005" | ||
| } | ||
| }, | ||
| "request": { | ||
| "operation": "retry_side_effect", | ||
| "now": "2030-01-01T00:10:00.000Z", | ||
| "idempotency_key": "wrong-key" | ||
| }, | ||
| "expected": { | ||
| "allowed": false, | ||
| "posture": "REVALIDATE", | ||
| "reason": "RETRY_NOT_PROVEN_SAFE" | ||
| } | ||
| }, | ||
| { | ||
| "case_id": "failed_response_requires_remediation", | ||
| "snapshot": { | ||
| "transition_id": "transition-006", | ||
| "subject_id": "agent:reporter", | ||
| "action_identity_digest": "$REF:action-006", | ||
| "binding_digest": "$REF:binding-006", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-006", | ||
| "observation_refs": ["$REF:observation-006"], | ||
| "response_integrity_ref": "$REF:integrity-006", | ||
| "causal_audit_ref": "$REF:causal-006" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "VALID", | ||
| "execution": "OBSERVED_ERRORED", | ||
| "response_integrity": "FAILED", | ||
| "causal_validity": "VALID" | ||
| }, | ||
| "side_effect_committed": false, | ||
| "authority_expires_at": "2030-01-01T01:00:00.000Z", | ||
| "context_digest": "$REF:context-006" | ||
| }, | ||
| "request": { | ||
| "operation": "remediate_response", | ||
| "now": "2030-01-01T00:10:00.000Z" | ||
| }, | ||
| "expected": { | ||
| "allowed": true, | ||
| "posture": "REMEDIATE_RESPONSE", | ||
| "reason": "RESPONSE_REMEDIATION_ONLY" | ||
| } | ||
| }, | ||
| { | ||
| "case_id": "causal_invalid_blocks_continuation", | ||
| "snapshot": { | ||
| "transition_id": "transition-007", | ||
| "subject_id": "agent:deploy", | ||
| "action_identity_digest": "$REF:action-007", | ||
| "binding_digest": "$REF:binding-007", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-007", | ||
| "observation_refs": [], | ||
| "response_integrity_ref": null, | ||
| "causal_audit_ref": "$REF:causal-007" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "VALID", | ||
| "execution": "NOT_OBSERVED", | ||
| "response_integrity": "NOT_EVALUATED", | ||
| "causal_validity": "INVALID" | ||
| }, | ||
| "side_effect_committed": false, | ||
| "authority_expires_at": "2030-01-01T01:00:00.000Z", | ||
| "context_digest": "$REF:context-007" | ||
| }, | ||
| "request": { | ||
| "operation": "resume_side_effect", | ||
| "now": "2030-01-01T00:10:00.000Z" | ||
| }, | ||
| "expected": { | ||
| "allowed": false, | ||
| "posture": "BLOCKED", | ||
| "reason": "CAUSAL_LINEAGE_INVALID" | ||
| } | ||
| }, | ||
| { | ||
| "case_id": "evidence_drift_requires_new_snapshot", | ||
| "snapshot": { | ||
| "transition_id": "transition-008", | ||
| "subject_id": "agent:deploy", | ||
| "action_identity_digest": "$REF:action-008", | ||
| "binding_digest": "$REF:binding-008", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-008", | ||
| "observation_refs": [], | ||
| "response_integrity_ref": null, | ||
| "causal_audit_ref": "$REF:causal-008" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "VALID", | ||
| "execution": "NOT_OBSERVED", | ||
| "response_integrity": "NOT_EVALUATED", | ||
| "causal_validity": "VALID" | ||
| }, | ||
| "side_effect_committed": false, | ||
| "authority_expires_at": "2030-01-01T01:00:00.000Z", | ||
| "context_digest": "$REF:context-008" | ||
| }, | ||
| "request": { | ||
| "operation": "resume_side_effect", | ||
| "now": "2030-01-01T00:10:00.000Z", | ||
| "current_evidence_set_digest": "$REF:different-evidence" | ||
| }, | ||
| "expected": { | ||
| "allowed": false, | ||
| "posture": "REVALIDATE", | ||
| "reason": "EVIDENCE_DRIFT" | ||
| } | ||
| }, | ||
| { | ||
| "case_id": "stale_snapshot_remains_reportable", | ||
| "snapshot": { | ||
| "transition_id": "transition-009", | ||
| "subject_id": "agent:deploy", | ||
| "action_identity_digest": "$REF:action-009", | ||
| "binding_digest": "$REF:binding-009", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-009", | ||
| "observation_refs": ["$REF:observation-009"], | ||
| "response_integrity_ref": "$REF:integrity-009", | ||
| "causal_audit_ref": "$REF:causal-009" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "EXPIRED_AT_REPORT", | ||
| "execution": "OBSERVED_EXECUTED", | ||
| "response_integrity": "VERIFIED", | ||
| "causal_validity": "VALID" | ||
| }, | ||
| "side_effect_committed": true, | ||
| "authority_expires_at": "2030-01-01T00:05:00.000Z", | ||
| "context_digest": "$REF:context-009" | ||
| }, | ||
| "request": { | ||
| "operation": "report_only", | ||
| "now": "2030-01-01T00:10:00.000Z", | ||
| "current_evidence_set_digest": "$REF:newer-evidence", | ||
| "current_context_digest": "$REF:newer-context" | ||
| }, | ||
| "expected": { | ||
| "allowed": true, | ||
| "posture": "REPORT_ONLY", | ||
| "reason": "HISTORICAL_REPORT_ONLY" | ||
| } | ||
| }, | ||
| { | ||
| "case_id": "cross_subject_substitution_fails", | ||
| "snapshot": { | ||
| "transition_id": "transition-010", | ||
| "subject_id": "agent:deploy", | ||
| "action_identity_digest": "$REF:action-010", | ||
| "binding_digest": "$REF:binding-010", | ||
| "record_refs": { | ||
| "authorization_ref": "$REF:auth-010", | ||
| "observation_refs": [], | ||
| "response_integrity_ref": null, | ||
| "causal_audit_ref": "$REF:causal-010" | ||
| }, | ||
| "dimensions": { | ||
| "authority": "VALID", | ||
| "execution": "NOT_OBSERVED", | ||
| "response_integrity": "NOT_EVALUATED", | ||
| "causal_validity": "VALID" | ||
| }, | ||
| "side_effect_committed": false, | ||
| "authority_expires_at": "2030-01-01T01:00:00.000Z", | ||
| "context_digest": "$REF:context-010" | ||
| }, | ||
| "request": { | ||
| "operation": "resume_side_effect", | ||
| "now": "2030-01-01T00:10:00.000Z", | ||
| "subject_id": "agent:other" | ||
| }, | ||
| "expected": { | ||
| "allowed": false, | ||
| "posture": "BLOCKED", | ||
| "reason": "SUBJECT_MISMATCH" | ||
| } | ||
| } | ||
| ] | ||
| } |
There was a problem hiding this comment.
2. Missing tampering fixture case 📎 Requirement gap ⛨ Security
The new trustworthy-transition fixture suite does not include a tampering scenario, despite the compliance requirement to cover snapshot tampering acceptance/fail-closed behavior. This leaves the adapter’s restart/resume security properties unverified under adversarial mutation.
Agent Prompt
## Issue description
The fixture suite for the trustworthy-transition continuity adapter lacks a tampering case (e.g., one-byte mutation / payload mutation) to ensure mutated snapshots are rejected (fail closed).
## Issue Context
The test harness asserts the fixture contains exactly 10 cases and replays them after restart, but none of those cases represent a tampered snapshot. PR Compliance ID 8 explicitly requires fixtures for tampering scenarios.
## Fix Focus Areas
- packages/ls-continuity-store/tests/fixtures/trustworthy-transition-continuity-v0.1.json[1-350]
- packages/ls-continuity-store/tests/trustworthy-transition.test.ts[95-126]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| switch (payload.dimensions.authority) { | ||
| case "DENIED": | ||
| return decision(snapshot, false, "BLOCKED", "AUTHORITY_DENIED"); | ||
| case "PENDING": | ||
| return decision(snapshot, false, "BLOCKED", "AUTHORITY_PENDING", [ | ||
| "resolve_pending_approval" | ||
| ]); | ||
| case "EXPIRED": | ||
| case "EXPIRED_AT_REPORT": | ||
| return decision(snapshot, false, "BLOCKED", "AUTHORITY_EXPIRED", [ | ||
| "obtain_fresh_authorization" | ||
| ]); | ||
| case "CONSUMED": | ||
| return decision(snapshot, false, "ALREADY_CONSUMED", "AUTHORITY_CONSUMED"); | ||
| case "REVALIDATION_REQUIRED": | ||
| return decision( | ||
| snapshot, | ||
| false, | ||
| "REVALIDATE", | ||
| "AUTHORITY_REVALIDATION_REQUIRED", | ||
| ["revalidate_authority"] | ||
| ); | ||
| case "NOT_EVALUATED": | ||
| case "UNKNOWN": | ||
| return decision( | ||
| snapshot, | ||
| false, | ||
| "REVALIDATE", | ||
| "AUTHORITY_NOT_EVALUATED", | ||
| ["evaluate_authority"] | ||
| ); | ||
| case "VALID": | ||
| break; | ||
| } |
There was a problem hiding this comment.
3. Unvalidated dimensions fail-open 🐞 Bug ⛨ Security
evaluateTransitionResume() has no fail-closed handling for unexpected persisted dimension values (notably authority), and verification_receipt payloads are not runtime-validated; a snapshot containing an unknown authority/causal/integrity string can bypass safety branches and reach CONTINUE_SIDE_EFFECT. This can incorrectly allow resuming a side effect from a malformed or cross-producer snapshot instead of returning BLOCKED/REVALIDATE.
Agent Prompt
## Issue description
`verification_receipt` payloads are accepted as `z.record(z.unknown())`, and `verifyStoredSnapshot()` only checks profile/subject/evidence_set_digest. In `evaluateTransitionResume()`, the `authority` switch has no `default`, so any unexpected runtime string (e.g., from a cross-producer snapshot or untyped JSON) will fall through as if authority were acceptable and may allow `CONTINUE_SIDE_EFFECT`.
## Issue Context
TypeScript unions protect well-typed call sites, but persisted data can still be malformed (other producers writing to the same store, older snapshots, manual fixtures, or accidental `as any`). The adapter should fail closed on any unknown dimension values.
## Fix Focus Areas
- packages/ls-continuity-store/src/schema.ts[84-92]
- packages/ls-continuity-store/src/store.ts[34-43]
- packages/ls-continuity-store/src/trustworthy-transition.ts[309-325]
- packages/ls-continuity-store/src/trustworthy-transition.ts[392-469]
## Suggested fix
1. Add a runtime validator for `TransitionContinuitySnapshotPayload` (zod schema) that enforces:
- `dimensions.authority/execution/response_integrity/causal_validity` are in the allowed enums
- `context_digest` matches `sha256:<hex>`
- optional times are either `null` or valid datetime strings
2. Call this validator inside `verifyStoredSnapshot()` (and optionally in `createTransitionSnapshotEnvelope()` as a final guard).
3. Add `default` branches (or explicit else fallbacks) in `evaluateTransitionResume()` for any dimension comparisons/switches so that unknown values return a deterministic fail-closed decision (e.g., `BLOCKED`/`REVALIDATE` with reason `UNKNOWN_STATE`).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| parseTime(createdAt, "created_at"); | ||
| requireObjectRef(previousRef, "previous_ref"); | ||
| requireObjectRef(input.context_digest, "context_digest"); | ||
| requireObjectRef(input.reauthorization_ref ?? null, "reauthorization_ref"); | ||
| if (input.authority_expires_at) parseTime(input.authority_expires_at, "authority_expires_at"); | ||
|
|
There was a problem hiding this comment.
4. Empty expires_at crashes evaluation 🐞 Bug ☼ Reliability
createTransitionSnapshotEnvelope() only validates authority_expires_at when it is truthy, so an empty string can be persisted without validation and later causes parseTime() to throw during evaluateTransitionResume(). This turns malformed input into a runtime exception instead of returning a deterministic BLOCKED/REVALIDATE decision.
Agent Prompt
## Issue description
`authority_expires_at` validation is inconsistent:
- Snapshot creation validates only when `input.authority_expires_at` is truthy, so `""` bypasses validation.
- Snapshot payload stores `authority_expires_at: input.authority_expires_at ?? null`, so `""` is persisted.
- Resume evaluation parses whenever `payload.authority_expires_at !== null`, so it attempts to parse `""` and throws.
## Issue Context
This creates a crash path for a plausible edge value (empty string from UI/config/JSON), breaking deterministic resume evaluation.
## Fix Focus Areas
- packages/ls-continuity-store/src/trustworthy-transition.ts[223-233]
- packages/ls-continuity-store/src/trustworthy-transition.ts[182-186]
- packages/ls-continuity-store/src/trustworthy-transition.ts[427-434]
## Suggested fix
1. Change the creation-time check to validate whenever the value is not null/undefined, not truthy:
- `if (input.authority_expires_at != null) parseTime(input.authority_expires_at, ...)`
2. Consider normalizing empty strings to `null` (or rejecting them) before persisting:
- e.g., `const expiresAt = input.authority_expires_at?.trim() || null;`
3. Optionally harden `verifyStoredSnapshot()` to validate `authority_expires_at` (if non-null) so malformed stored snapshots fail fast with a controlled error.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
packages/ls-continuity-store/tests/fixtures/trustworthy-transition-continuity-v0.1.json (1)
4-349: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winFixture covers the main happy/blocked paths but misses several fail-closed branches.
All 10 cases check out against
evaluateTransitionResume's branching order (identity checks, report_only, evidence/context drift, causal validity, remediation, expiry, side-effect/execution consumption, subject substitution). However, several documented resume postures and safety branches aren't exercised anywhere in this fixture-driven suite:
authoritystatesDENIED,PENDING,CONSUMED(via the authority switch, not the side-effect-committed short-circuit),REVALIDATION_REQUIRED,NOT_EVALUATED/UNKNOWN.causal_validitystatesUNKNOWN/NOT_EVALUATED(onlyVALID/INVALIDare covered).response_integrity: PARTIAL, andFAILEDreached via a non-remediate_responseoperation (the directRESPONSE_INTEGRITY_FAILEDbranch).execution: OBSERVED_BLOCKED.Given the PR's explicit emphasis on fail-closed behavior and adversarial test coverage, adding a few more cases for these branches would materially increase confidence in the safety-critical resume logic.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ls-continuity-store/tests/fixtures/trustworthy-transition-continuity-v0.1.json` around lines 4 - 349, The fixture for evaluateTransitionResume is missing several fail-closed branches, so add cases that exercise the uncovered authority, causal, response-integrity, and execution states. Extend trustworthy-transition-continuity-v0.1.json with scenarios for authority values like DENIED, PENDING, CONSUMED, REVALIDATION_REQUIRED, and UNKNOWN/NOT_EVALUATED, plus causal_validity UNKNOWN/NOT_EVALUATED, response_integrity PARTIAL and FAILED on a non-remediate_response path, and execution OBSERVED_BLOCKED. Keep the new cases aligned with the existing case_id/request/expected pattern so the branching order in evaluateTransitionResume is validated end-to-end.packages/ls-continuity-store/src/transition-demo.ts (1)
17-89: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTemp directory from
mkdtempSyncis never removed.
rootis created viafs.mkdtempSyncbut nothing callsfs.rmSync(root, { recursive: true, force: true }), even on the happy path. Repeated local runs ofnpm run demo:transitionwill accumulate orphaned directories under the OS tmp folder.♻️ Proposed cleanup
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ls-transition-demo-")); const databasePath = path.join(root, "continuity.db"); const objectsPath = path.join(root, "objects"); -const db = openDatabase(databasePath); -const store = new ContinuityStore(db, objectsPath); -const snapshot = persistTransitionSnapshot( - store, - { ... }, - "2030-01-01T00:00:00.000Z" -); -db.close(); - -const reopenedDb = openDatabase(databasePath); -const reopenedStore = new ContinuityStore(reopenedDb, objectsPath); -const recovered = reopenedStore.load<typeof snapshot.payload>(snapshot.object_id); - -... - -reopenedDb.close(); +try { + const db = openDatabase(databasePath); + const store = new ContinuityStore(db, objectsPath); + const snapshot = persistTransitionSnapshot( + store, + { ... }, + "2030-01-01T00:00:00.000Z" + ); + db.close(); + + const reopenedDb = openDatabase(databasePath); + const reopenedStore = new ContinuityStore(reopenedDb, objectsPath); + const recovered = reopenedStore.load<typeof snapshot.payload>(snapshot.object_id); + + ... + + reopenedDb.close(); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ls-continuity-store/src/transition-demo.ts` around lines 17 - 89, The demo in transition-demo.ts creates a temp directory with fs.mkdtempSync via root, but never removes it after use. Add cleanup around the existing openDatabase/ContinuityStore/persistTransitionSnapshot flow so the temporary root is removed with fs.rmSync(..., { recursive: true, force: true }) even on success or failure, and make sure both reopenedDb and the temp directory are cleaned up reliably.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/ls-continuity-store.yml:
- Line 25: The checkout step in the ls-continuity-store workflow should disable
persisted git credentials because this job is read-only. Update the
actions/checkout@v4 usage in the workflow to set persist-credentials to false so
no token is written to disk.
In `@packages/ls-continuity-store/src/trustworthy-transition.ts`:
- Line 232: The creation path in trustworthy-transition.ts is letting
empty-string authority_expires_at slip through because the validation guard only
checks truthiness and the payload later preserves "" via nullish coalescing.
Update the validation around the input.authority_expires_at check so any
non-null value, including "", is passed to parseTime and rejected immediately if
invalid; also make sure the payload construction normalizes empty strings to
null before it reaches evaluateTransitionResume.
---
Nitpick comments:
In `@packages/ls-continuity-store/src/transition-demo.ts`:
- Around line 17-89: The demo in transition-demo.ts creates a temp directory
with fs.mkdtempSync via root, but never removes it after use. Add cleanup around
the existing openDatabase/ContinuityStore/persistTransitionSnapshot flow so the
temporary root is removed with fs.rmSync(..., { recursive: true, force: true })
even on success or failure, and make sure both reopenedDb and the temp directory
are cleaned up reliably.
In
`@packages/ls-continuity-store/tests/fixtures/trustworthy-transition-continuity-v0.1.json`:
- Around line 4-349: The fixture for evaluateTransitionResume is missing several
fail-closed branches, so add cases that exercise the uncovered authority,
causal, response-integrity, and execution states. Extend
trustworthy-transition-continuity-v0.1.json with scenarios for authority values
like DENIED, PENDING, CONSUMED, REVALIDATION_REQUIRED, and
UNKNOWN/NOT_EVALUATED, plus causal_validity UNKNOWN/NOT_EVALUATED,
response_integrity PARTIAL and FAILED on a non-remediate_response path, and
execution OBSERVED_BLOCKED. Keep the new cases aligned with the existing
case_id/request/expected pattern so the branching order in
evaluateTransitionResume is validated end-to-end.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 4ccf533d-86af-4ee8-ba20-f805d7b98f47
📒 Files selected for processing (9)
.github/workflows/ls-continuity-store.ymlpackages/ls-continuity-store/README.mdpackages/ls-continuity-store/docs/TRUSTWORTHY_TRANSITION_CONTINUITY_V0_1.mdpackages/ls-continuity-store/package.jsonpackages/ls-continuity-store/src/demo.tspackages/ls-continuity-store/src/transition-demo.tspackages/ls-continuity-store/src/trustworthy-transition.tspackages/ls-continuity-store/tests/fixtures/trustworthy-transition-continuity-v0.1.jsonpackages/ls-continuity-store/tests/trustworthy-transition.test.ts
| run: | ||
| working-directory: packages/ls-continuity-store | ||
| steps: | ||
| - uses: actions/checkout@v4 |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Add persist-credentials: false to checkout.
This job never mutates the repo, so persisting the git credential on disk is unnecessary attack surface — flagged by zizmor's artipacked audit.
🔒️ Proposed fix
- uses: actions/checkout@v4
+ with:
+ persist-credentials: false📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| - uses: actions/checkout@v4 | |
| - uses: actions/checkout@v4 | |
| with: | |
| persist-credentials: false |
🧰 Tools
🪛 zizmor (1.26.1)
[warning] 25-25: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/ls-continuity-store.yml at line 25, The checkout step in
the ls-continuity-store workflow should disable persisted git credentials
because this job is read-only. Update the actions/checkout@v4 usage in the
workflow to set persist-credentials to false so no token is written to disk.
Source: Linters/SAST tools
| requireObjectRef(previousRef, "previous_ref"); | ||
| requireObjectRef(input.context_digest, "context_digest"); | ||
| requireObjectRef(input.reauthorization_ref ?? null, "reauthorization_ref"); | ||
| if (input.authority_expires_at) parseTime(input.authority_expires_at, "authority_expires_at"); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Empty-string authority_expires_at is accepted at creation but breaks every later evaluation.
The truthy guard skips validation for "" (falsy), and line 273's input.authority_expires_at ?? null preserves "" (not nullish) in the payload. Later, evaluateTransitionResume (line 428) sees payload.authority_expires_at !== null as true and calls parseTime(""), which is NaN and throws AUTHORITY_EXPIRES_AT_INVALID. The snapshot persists successfully but can never be resumed. Validate non-null values so bad input is rejected up front.
🐛 Proposed fix
- if (input.authority_expires_at) parseTime(input.authority_expires_at, "authority_expires_at");
+ if (input.authority_expires_at != null) parseTime(input.authority_expires_at, "authority_expires_at");📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (input.authority_expires_at) parseTime(input.authority_expires_at, "authority_expires_at"); | |
| if (input.authority_expires_at != null) parseTime(input.authority_expires_at, "authority_expires_at"); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ls-continuity-store/src/trustworthy-transition.ts` at line 232, The
creation path in trustworthy-transition.ts is letting empty-string
authority_expires_at slip through because the validation guard only checks
truthiness and the payload later preserves "" via nullish coalescing. Update the
validation around the input.authority_expires_at check so any non-null value,
including "", is passed to parseTime and rejected immediately if invalid; also
make sure the payload construction normalizes empty strings to null before it
reaches evaluateTransitionResume.
Disposition: PARKED stacked follow-upThis PR remains intentionally stacked on #766 and must not move independently. Preserve it as the trustworthy-transition continuity experiment; resume only after an explicit product decision revives the Continuity Store line. Until then it is not an active v0.1 delivery blocker. |
Summary
Implements LS #768 as a stacked follow-up to draft PR #766.
This PR adds a provider-neutral continuity adapter that persists exact trustworthy-transition record references and derives safe resume posture across pause, restart, retry, and context drift.
Base dependency
This PR is intentionally stacked on:
feat/continuity-store-v0.1It must not be retargeted to
mainor merged before #766 lands.The head branch has been fast-forwarded onto the current base head. It is
behind_by: 0, mergeable, and its diff contains only transition-adapter files; the package workflow is inherited from #766.Added
src/trustworthy-transition.tssrc/transition-demo.tstests/fixtures/trustworthy-transition-continuity-v0.1.jsontests/trustworthy-transition.test.tsdocs/TRUSTWORTHY_TRANSITION_CONTINUITY_V0_1.mddemo:transitionpackage scriptSnapshot model
Each transition snapshot is stored as an immutable
verification_receiptand binds:Resume postures
CONTINUE_SIDE_EFFECTRETRY_SIDE_EFFECTREPORT_ONLYREMEDIATE_RESPONSEREVALIDATEBLOCKEDALREADY_CONSUMEDSafety rules
Snapshot-chain monotonicity
The chain assessment rejects:
previous_ref;Reopening terminal authority requires a different authorization reference plus an explicit matching
reauthorization_ref.Validation
The inherited
LS Continuity Storeworkflow is green on the final head and ran:All steps passed, including TypeScript compilation, unit/restart tests, the base continuity demo, and the trustworthy-transition continuity demo.
The fixture suite persists every scenario to SQLite/WAL, closes the database, reopens it, loads the immutable object, and only then evaluates continuation.
The base PR #766 is also green for:
Review status
Boundary
LS persists continuity and derives continuation posture only. It does not issue authorization, observe execution, verify response claims, or decide causal validity.
Merge gate
Keep this PR draft. Do not merge until:
mainand rerun on the landed base;Relates to #768 and safal207/Liminal#108.
Summary by CodeRabbit
New Features
Bug Fixes
Documentation