Skip to content

Commit 969c33d

Browse files
fix: preserve resync absence and profile admission safety (#364)
* fix: preserve resync absence and profile admission safety * fix: retain admission spelling and retry baseline reads * fix(agent-spec): key refresh cache by declared module path * fix(agent-spec): key cache by containment spelling --------- Co-authored-by: Johannes Schickling <schickling.j@gmail.com>
1 parent 93168a4 commit 969c33d

8 files changed

Lines changed: 883 additions & 231 deletions

File tree

crates/agent-spec/src/profile.rs

Lines changed: 279 additions & 20 deletions
Large diffs are not rendered by default.

docs/vrs/.decisions/0008-resync-events-ride-the-built-in-stream.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Resync events ride the built-in stream with occurrence-scoped digest identity
1+
# Resync events ride the built-in stream with occurrence-scoped carrier-state identity
22

33
Status: accepted
44

@@ -94,6 +94,30 @@ occurrence store.
9494
Q13 contract. The composition experiment records these tests as the direct
9595
evidence.
9696

97+
## Amendment — 2026-08-27 (Q21)
98+
99+
Johannes approved decision request Q21 to make carrier absence an observable
100+
state rather than an unreadable digest. A subscription now records
101+
`present(<digest>)` or `missing`. A proven present→missing transition emits a
102+
canonical tombstone (`old: <digest>`, `new: missing`), and missing→present
103+
emits a creation even if the recreated bytes have the same digest as before
104+
deletion. Repeated missing observations remain silent.
105+
106+
Only `ENOENT` and a stable non-regular replacement establish `missing`.
107+
Permission and transient I/O failures retain the last proven state, emit a
108+
diagnostic, and retry. Failed delivery retains the exact carrier-state
109+
transition body, occurrence, and identity; a newer observation is considered
110+
only after that immutable reservation completes. Per-binding supersession and
111+
shared-carrier subscriber independence remain unchanged.
112+
113+
`deletion_and_same_byte_recreation_are_distinct_carrier_transitions`,
114+
`initial_transient_read_failure_schedules_a_baseline_retry`,
115+
`transient_permission_error_retries_without_emitting_a_tombstone`,
116+
`fifo_carrier_is_rejected_without_blocking`,
117+
`failed_tombstone_emit_retains_present_state_and_immutable_retry_snapshot`,
118+
and the existing shared-subscriber occurrence tests prove the Q21 contract.
119+
120+
97121
## Options
98122

99123
| Option | Result | Reason |

docs/vrs/06-resync/.experiments/2026-08-25-resync-composition.md

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,29 @@ Q13 occurrence identity is covered by four focused unit tests in
3939
4. `repeated_identical_transitions_receive_distinct_occurrence_identities`
4040
drives A→B, B→A, A→B through the real event ingress and proves the repeated
4141
A→B legs have distinct IDs and subscription sequences 1 and 3.
42-
5. `failed_emit_retains_digest_and_schedules_the_same_transition_for_retry`
43-
proves a failed retry retains the exact immutable body, event ID, and
44-
sequence.
42+
5. `failed_tombstone_emit_retains_present_state_and_immutable_retry_snapshot`
43+
proves a failed tombstone retains the exact immutable body, event ID, state,
44+
and sequence across same-byte recreation.
4545
6. `subscribers_advance_occurrence_sequences_independently` — proves two
4646
subscriptions each capture their first transition at sequence 1.
4747
7. `supervisor_restart_incarnation_changes_the_occurrence_namespace` — proves
4848
an incarnation change produces a different body and ID even when the
4949
per-subscription sequence and digest transition repeat.
5050

51+
Q21 carrier-state semantics are covered by three additional focused tests:
52+
53+
8. `deletion_and_same_byte_recreation_are_distinct_carrier_transitions`
54+
proves present→missing emits `old: <digest>` / `new: missing`, repeated
55+
missing stays silent, and same-byte recreation emits missing→present as the
56+
next occurrence while superseding the tombstone.
57+
9. `initial_transient_read_failure_schedules_a_baseline_retry` and
58+
`transient_permission_error_retries_without_emitting_a_tombstone` prove an
59+
initial or later permission failure retains unknown/prior state and
60+
schedules retry; after access recovers, baseline seeding or the real byte
61+
transition completes without an intermediate tombstone.
62+
10. `digesting_a_fifo_fails_without_blocking_the_worker` — proves both native
63+
and confined readers classify a FIFO as stable missing without blocking.
64+
5165
Three design constraints were discovered by running the code, not by reasoning:
5266

5367
- **Directory-creation blind spot.** Writing a file into a newly created
@@ -68,21 +82,25 @@ Three design constraints were discovered by running the code, not by reasoning:
6882

6983
## Result
7084

71-
The three integration tests and focused resync unit suite pass, including the
72-
four Q13 occurrence tests. Pre-existing suite results remain recorded in the
73-
PR description; unrelated formatting drift was left untouched.
85+
The default resync unit slice passed 31 tests, including every Q13/Q21 state,
86+
retry, shared-subscriber, and confinement case. The default Agent Spec suite
87+
passed 78 tests, and the `wasm-resolver` Agent Spec suite passed 110 unit,
88+
integration, and hostile-module tests. The real resync integration suite passed
89+
three default and five wasm-enabled filesystem/inbox cases. Pre-existing
90+
warnings were unchanged.
7491

7592
## Conclusion
7693

7794
The composition holds without new delivery semantics: DING, archive, ring
78-
dedup, and supersession are inherited untouched. The two discoveries above are
79-
now pinned by the tests that exposed them.
95+
dedup, and supersession are inherited untouched. Carrier absence is now a
96+
first-class transition without turning transient read failures into false
97+
tombstones.
8098

8199
## VRS Impact
82100

83101
Supports [`06-resync/requirements.md`](../../06-resync/requirements.md)
84-
RESYNC-R01/R03/R04/R06/R07 and [`spec.md`](../../06-resync/spec.md). The Q13
85-
evidence supports the approved RESYNC-R06 occurrence-identity amendment and
86-
decision record 0008. The catalog-relative resource URI form remains an st2
87-
extension pending canonical Agent Spec adoption (noted in
102+
RESYNC-R01/R03/R04/R06/R07 and [`spec.md`](../../06-resync/spec.md). The
103+
Q13/Q21 evidence supports the approved RESYNC-R06 occurrence and carrier-state
104+
amendments to decision record 0008. The catalog-relative resource URI form
105+
remains an st2 extension pending canonical Agent Spec adoption (noted in
88106
[`02-agent-spec`](../../02-agent-spec/requirements.md) terms).

docs/vrs/06-resync/requirements.md

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ root [`R05`](../requirements.md); Resource bindings are defined by
5151
provisional constants tuned by observation, per the rollout note in
5252
[issue #341](https://github.com/compoundingtech/st2/issues/341).
5353
- **RESYNC-T03 No catch-up:** Like any restarted stream, a restarted
54-
supervisor re-observes current digests silently and emits only subsequent
55-
changes. Changes missed while the supervisor was down are discovered by the
56-
agent's own next read or by reconcile, not reconstructed as events.
54+
supervisor re-observes current carrier state silently and emits only
55+
subsequent changes. Changes missed while the supervisor was down are
56+
discovered by the agent's own next read or by reconcile, not reconstructed
57+
as events.
5758

5859
## Requirements
5960

@@ -70,9 +71,11 @@ root [`R05`](../requirements.md); Resource bindings are defined by
7071
watches and why, denies everything else by default, ignores read/open
7172
access events, and never traverses payload trees — refining [`R14`](../requirements.md)
7273
and the mutation-only wakeup invariant.
73-
- **RESYNC-R03 Seeded baseline:** On supervisor start the current digest of
74-
every watchable carrier is recorded without emitting. Only a transition
75-
between observed contents produces an event; a restart alone wakes nobody.
74+
- **RESYNC-R03 Seeded baseline:** On supervisor start every watchable carrier
75+
is recorded as either `present(<content-digest>)` or `missing` without
76+
emitting. Only a transition between proven states produces an event; a
77+
restart alone wakes nobody. Permission and transient I/O failures establish
78+
neither state and are retried rather than interpreted as absence.
7679

7780
### Must classify before notifying
7881

@@ -92,19 +95,21 @@ root [`R05`](../requirements.md); Resource bindings are defined by
9295

9396
### Must emit honest, deduplicated events
9497

95-
- **RESYNC-R06 Event shape and occurrence identity:** One carrier change
98+
- **RESYNC-R06 Event shape and occurrence identity:** One carrier state change
9699
becomes one stream event on the built-in `resync` stream: subject
97100
`resource <binding> changed`, body naming the binding label, resolved path,
98-
old and new content digests, and an occurrence token. The token combines
99-
the current supervisor incarnation — catalog-lock device/inode plus
100-
supervisor PID/start-time ticks — with a sequence retained independently by
101-
each subscription. A subscription advances its sequence only when it
102-
captures a new immutable transition; a failed publication retries the same
103-
body, token, and identity. The event identity is the SHA-256 of that
104-
canonical rendered body. Thus replay is stable, while A→B, B→A, A→B gives
105-
the repeated A→B legs distinct identities. The grouping key is the binding
106-
label; every emit declares supersession so a binding collapses to one unread
107-
head.
101+
old and new carrier states, and an occurrence token. A present state renders
102+
as its content digest and absence renders canonically as `missing`, so
103+
deletion and recreation are observable even when recreated bytes equal the
104+
prior bytes. The token combines the current supervisor incarnation —
105+
catalog-lock device/inode plus supervisor PID/start-time ticks — with a
106+
sequence retained independently by each subscription. A subscription
107+
advances its sequence only when it captures a new immutable transition; a
108+
failed publication retries the same body, token, and identity. The event
109+
identity is the SHA-256 of that canonical rendered body. Thus replay is
110+
stable, while repeated state-transition legs receive distinct identities.
111+
The grouping key is the binding label; every emit declares supersession so a
112+
binding collapses to one unread head.
108113
- **RESYNC-R07 Built-in stream:** The `resync` stream exists on every agent
109114
without declaration and is reserved: a user-declared stream of that name is
110115
refused. Only the supervisor's crate-internal publisher admits the built-in
@@ -113,12 +118,13 @@ root [`R05`](../requirements.md); Resource bindings are defined by
113118
supersession semantics, inbox transport, and DING `»` marker inherited from
114119
[`STREAM-R03..R07`](../04-stream/requirements.md).
115120
- **RESYNC-R08 Lifecycle honesty:** No events accumulate for suspended or
116-
retired agents. Digest seeding happens when a seat launches or resumes, so
117-
resume re-observes current state silently and only later transitions notify.
121+
retired agents. Carrier-state seeding happens when a seat launches or
122+
resumes, so resume re-observes current state silently and only later
123+
transitions notify.
118124

119125
## Evidence
120126

121-
The composition (parent-directory watch → classification → digest-keyed
122-
superseded emit → DING wake) is proven by integration tests introduced with
123-
the implementation; see [`.experiments/`](./.experiments/) for the record
124-
and the pre-existing failure baseline it was verified against.
127+
The composition (parent-directory watch → classification → carrier-state-keyed
128+
superseded emit → DING wake) is proven by integration tests introduced with the
129+
implementation; see [`.experiments/`](./.experiments/) for the record and the
130+
pre-existing failure baseline it was verified against.

docs/vrs/06-resync/spec.md

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ writer (Nix activation / tool / agent)
1616
v inotify mutation on the parent directory (non-recursive)
1717
resync watcher thread
1818
|- classify carrier: immediate | silent | coalesced
19-
|- digest new bytes (sha256), compare to seeded/last digest
19+
|- observe present(sha256) | missing; compare to seeded/last state
2020
'- equal → nothing; changed → queue per class window
2121
|
2222
v window boundary
@@ -84,26 +84,29 @@ meaningful.
8484
launched/restarted in that pass. Desired declarations, dead keep-retained
8585
seats, and companion-only launches never become watched. If strict discovery
8686
temporarily rejects a declaration whose exact canonical seat remains
87-
observed alive, its prior declaration subscription survives with its digest
88-
and pending transition; it drops as soon as that seat is not live. Existing
89-
valid subscriptions are matched by declaration path and binding label. Each
90-
refresh takes bus id, canonical seat id, carrier path, label, and class from
91-
the current declaration while retaining digest, the per-subscription
92-
occurrence sequence, any immutable pending transition, and dirty state; only
93-
new subscriptions seed silently with sequence zero.
94-
- A previously blind path is digest-diffed both before and after its recovered
87+
observed alive, its prior declaration subscription survives with its carrier
88+
state and pending transition; it drops as soon as that seat is not live.
89+
Existing valid subscriptions are matched by declaration path and binding
90+
label. Each refresh takes bus id, canonical seat id, carrier path, label, and
91+
class from the current declaration while retaining carrier state, the
92+
per-subscription occurrence sequence, any immutable pending transition, and
93+
dirty state; only new subscriptions seed silently with sequence zero.
94+
- A previously blind path is state-diffed both before and after its recovered
9595
parent watch is registered, closing the poll-to-registration gap.
96-
- Installation failure degrades to timer-based digest polling over the watch
96+
- Installation failure degrades to timer-based carrier polling over the watch
9797
set (bounded by the number of bindings), never to silence about the
9898
mechanism. Polling only marks observed changes dirty and schedules the
9999
carrier's ordinary class deadline; it neither bypasses coalescing nor emits
100100
ahead of an immutable pending transition.
101101
- A runtime watcher-backend error may mean mutation events were dropped, so it
102102
schedules every changed carrier through the same pending-aware classified
103-
path. Equal digests remain silent.
104-
- Digest reads open carriers nonblocking, accept regular files only, and feed
105-
bytes incrementally into SHA-256 with bounded memory; FIFOs, other special
106-
files, and large carriers cannot stall or exhaust the worker.
103+
path. Equal states remain silent.
104+
- Reads open carriers nonblocking and without following the final symlink
105+
(every component for confined carriers). A proven regular file becomes
106+
`present(<sha256>)`; `ENOENT` or a stable non-regular replacement becomes
107+
`missing`. Permission and transient I/O errors are diagnosed and retried
108+
without changing state. FIFOs and other special files therefore cannot stall
109+
the worker and produce one tombstone transition rather than silent ambiguity.
107110

108111
## Built-in stream
109112

@@ -120,18 +123,24 @@ grammar (1..=200 bytes, no surrounding whitespace or controls), and
120123
`declaration` is reserved for the synthetic declaration carrier so supersession
121124
keys cannot collide.
122125

123-
Digest and occurrence-sequence state live with the supervisor process (seeded
124-
at start) and have no durable store, consistent with RESYNC-T03. A captured
125-
transition gets an occurrence token
126+
Carrier state and occurrence-sequence state live with the supervisor process
127+
(seeded at start) and have no durable store, consistent with RESYNC-T03. A
128+
carrier state is `present(<sha256>)` or `missing`. Present→missing emits a
129+
canonical tombstone whose body contains `old: <digest>` and `new: missing`;
130+
missing→present emits a creation even when that digest matches the bytes from
131+
before deletion. Repeated missing observations are silent.
132+
133+
A captured transition gets an occurrence token
126134
`v1:<catalog-lock-dev>:<catalog-lock-inode>:<supervisor-pid>:<supervisor-start-time-ticks>:<subscription-sequence>`
127135
in its canonical body before that body is hashed for the event ID. Each
128136
subscription advances its sequence only when capturing a new immutable
129-
transition. Failed publication retains the old digest, exact target digest,
137+
transition. Failed publication retains the old state, exact target state,
130138
canonical body, occurrence token, and event ID; retry replays those bytes
131-
before observing a newer carrier digest. Repeated A→B legs therefore remain
132-
distinct occurrences, while a retry remains the same reservation. A supervisor
133-
restart changes the incarnation namespace and silently seeds new subscription
134-
sequences. The durable dedup horizon remains the stream receipt ring.
139+
before observing newer carrier state. Repeated transition legs therefore
140+
remain distinct occurrences, while a retry remains the same reservation. A
141+
supervisor restart changes the incarnation namespace and silently seeds new
142+
subscription sequences. The durable dedup horizon remains the stream receipt
143+
ring.
135144

136145
## What this does not do
137146

docs/vrs/07-resource-profile/.experiments/2026-08-26-plugin-boundary-comparison.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,32 @@ The evidence therefore supports a **wasm-only, feature-gated foundation** behind
165165
the chosen registry/SDK seam. The dependency and binary cost is real and is why
166166
the feature gate is constitutional, not optional cleanup.
167167

168+
### Admission-key cache safety follow-up
169+
170+
**Hypothesis:** A path-only refresh cache can reuse an externally admitted
171+
module for a catalog-relative declaration of the same absolute spelling,
172+
bypassing descriptor-relative no-follow traversal.
173+
174+
**Method:** Resolve a module through a symlinked parent as an external profile,
175+
then resolve the same spelling as catalog-relative in one refresh. Separately,
176+
resolve one regular module under external and contained policies through
177+
registry clones and replace module bytes between refreshes.
178+
179+
**Result:** `cache_normalization_does_not_change_the_path_used_for_admission`
180+
proves lexical normalization cannot erase filesystem-significant `..` after a
181+
symlink because admission still opens the declared spelling.
182+
`refresh_cache_cannot_reuse_external_admission_for_a_contained_module` proves
183+
the second resolution re-opens and rejects the symlink.
184+
`registry_clones_share_compilation_only_with_the_same_admission_policy` proves
185+
clones share one compilation within a policy and retain separate cache entries
186+
across policies. The existing replacement test proves a changed identity still
187+
invalidates its entry. The complete `wasm-resolver` Agent Spec suite passed 110
188+
tests.
189+
190+
**Conclusion:** Snapshot outcomes and compiled success/failure entries require
191+
one key: normalized module path plus external-or-exact-confinement-root policy,
192+
then file identity. Path identity alone is not an authorization boundary.
193+
168194
## VRS Impact
169195

170196
Supports [`PROFILE-R01..R08`](../requirements.md) and the sandbox/ABI sections

0 commit comments

Comments
 (0)