diff --git a/INVARIANTS.md b/INVARIANTS.md index 8e6a5a2c..c686cb63 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -35,4 +35,5 @@ materialization, messaging, DING, or presence must preserve them. | **Tracked workspaces fail closed** | Materialization simulates content operations before writing and refuses a real change to any Git-tracked target. Byte-identical tracked, untracked, and non-Git targets retain useful behavior. | `tests/materialize.rs::every_content_directive_refuses_to_change_a_tracked_target_before_any_write`; `tests/materialize.rs::byte_identical_tracked_target_is_allowed_without_modification`; `tests/materialize.rs::untracked_and_non_git_targets_remain_materializable` | | **Native flat root** | Without an authored override, catalog tasks, eval messaging, shell helpers, and DING all use the catalog itself as `ST_ROOT`; no nested bus directory is synthesized. | `src/eval_run.rs::bus_root_expands_st_root_else_defaults`; `tests/eval_run_e2e.rs::st2_eval_runs_a_benign_folder_to_a_pass_verdict`; `tests/pty.rs` | | **Resource observation is state-first, atomic, and fenced** | ABI-3 periodic publication and demanded `Published` results reuse one bounded `Publication` payload and one host acceptance, digest, relevance, typed-fact, and catch-up core; the host never trusts a runtime digest or observation timestamp. Demand reaches only a resident runtime that explicitly declares `capability "demand"`. Every `Observe` carries a positive watermark and the exact owner, binding, and registration, and exactly one matching `Unchanged`, `Failed`, or `Published` atomic result closes it. One outstanding dispatch plus one latest trailing watermark coalesces bursts without losing in-flight arrivals. Backpressure retains queued demand, replacement fences stale output, restart and provider failure settle honestly, and client disconnect or wait expiry never cancels accepted work. | `tests/resource_profile_supervisor_e2e.rs::demand_observation_settlement_matrix_is_atomic_and_preserves_facts`; `tests/resource_profile_supervisor_e2e.rs::demand_observation_coalesces_and_fences_watermarks`; `tests/resource_profile_supervisor_e2e.rs::demand_observation_survives_restart_disconnect_and_denies_missing_capability`; `tests/resource_profile_supervisor_e2e.rs::observable_publication_reaches_builtin_resync_with_filter_catch_up_and_scope_isolation`; `tests/agent_resource.rs::refresh_cli_reports_exact_receipts_and_wait_expiry_keeps_the_request`; `src/resource_observe.rs::tests::receipt_evidence_shape_matches_atomic_results` | +| **Atomic resource proposal publication** | Every changed resource publication is one host-owned compare-and-swap fenced by binding generation, state revision, and prior carrier digest. A persistent cross-process lock admits at most one proposal from the same prior. The content-derived proposal ID binds the accepted carrier digest and semantic outbox envelope; the durable intent becomes eligible only with the exact canonical carrier, then folds into one authoritative catch-up state. A pre-carrier crash exposes old state, a post-carrier crash catches up on restart, and retry after a lost acknowledgement returns the durable receipt without another transition. Ordinary reconciliation fails closed on out-of-band divergence after a committed intent; only an explicit generation-advance recovery may re-adopt the canonical carrier or its absence while invalidating the old intent and fence. | `src/resource_profile.rs::tests::atomic_publication_fences_races_and_survives_crash_restarts`; `src/resource_profile.rs::tests::generation_advance_explicitly_recovers_diverged_or_missing_carrier` | | **Proof references resolve** | Every qualified test named in this table exists in its named source file, so stale invariant claims fail the suite instead of silently surviving a refactor. | `tests/invariants.rs::qualified_proof_references_resolve` | diff --git a/crates/st2-resource-protocol/src/lib.rs b/crates/st2-resource-protocol/src/lib.rs index 05c6920f..6256b64a 100644 --- a/crates/st2-resource-protocol/src/lib.rs +++ b/crates/st2-resource-protocol/src/lib.rs @@ -543,6 +543,130 @@ impl<'de> Deserialize<'de> for SnapshotDigest { } } +/// Content-derived identity of one host-validated publication proposal. +/// +/// The host derives this after validating the publication. It is deliberately distinct from the +/// snapshot digest: the same bytes proposed against a different binding, generation, revision, +/// prior digest, selected-topic set, or ordered fact envelope are a different proposal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProposalId(SnapshotDigest); + +impl ProposalId { + pub fn of(bytes: &[u8]) -> Self { + Self(SnapshotDigest::of(bytes)) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + self.0.as_bytes() + } +} + +impl fmt::Display for ProposalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, formatter) + } +} + +/// Compare-and-swap fence captured before a provider computes a publication. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProposalFence { + generation: u64, + revision: u64, + #[serde(skip_serializing_if = "Option::is_none")] + prior_digest: Option, +} + +impl ProposalFence { + pub fn new( + generation: u64, + revision: u64, + prior_digest: Option, + ) -> Self { + Self { + generation, + revision, + prior_digest, + } + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn prior_digest(&self) -> Option { + self.prior_digest + } +} + +/// Durable receipt for one changed publication. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PublicationCommit { + proposal_id: ProposalId, + generation: u64, + revision: u64, + digest: SnapshotDigest, +} + +impl PublicationCommit { + pub fn new( + proposal_id: ProposalId, + generation: u64, + revision: u64, + digest: SnapshotDigest, + ) -> Self { + Self { + proposal_id, + generation, + revision, + digest, + } + } + + pub fn proposal_id(&self) -> ProposalId { + self.proposal_id + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn digest(&self) -> SnapshotDigest { + self.digest + } +} + +/// Result of the host's one authoritative compare-and-swap transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProposalCommit { + Committed(PublicationCommit), + AlreadyCommitted(PublicationCommit), + Unchanged { + generation: u64, + revision: u64, + digest: SnapshotDigest, + }, + StaleGeneration { + actual_generation: u64, + actual_revision: u64, + }, + StalePrior { + actual_generation: u64, + actual_revision: u64, + actual_digest: Option, + }, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde( tag = "type", @@ -993,6 +1117,33 @@ mod tests { } } + #[test] + fn proposal_fence_and_commit_receipt_are_domain_typed() { + let prior = SnapshotDigest::of(b"prior"); + let fence = ProposalFence::new(7, 11, Some(prior)); + assert_eq!( + serde_json::to_value(fence).unwrap(), + json!({ + "generation": 7, + "revision": 11, + "priorDigest": prior.to_string(), + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(fence).unwrap()).unwrap(), + fence + ); + + let proposal_id = ProposalId::of(b"proposal identity"); + let digest = SnapshotDigest::of(b"carrier"); + let commit = PublicationCommit::new(proposal_id, 7, 12, digest); + assert_eq!(commit.proposal_id(), proposal_id); + assert_eq!(commit.generation(), 7); + assert_eq!(commit.revision(), 12); + assert_eq!(commit.digest(), digest); + assert_ne!(proposal_id.as_bytes(), digest.as_bytes()); + } + #[test] fn host_frames_have_exact_json_shape_and_newline() { let register = HostMessage::Register { diff --git a/docs/vrs/.decisions/0009-resource-profiles-use-a-feature-gated-wasm-boundary.md b/docs/vrs/.decisions/0009-resource-profiles-use-a-feature-gated-wasm-boundary.md index a78f4e3e..4db691eb 100644 --- a/docs/vrs/.decisions/0009-resource-profiles-use-a-feature-gated-wasm-boundary.md +++ b/docs/vrs/.decisions/0009-resource-profiles-use-a-feature-gated-wasm-boundary.md @@ -126,3 +126,96 @@ unprojected inputs, and load a relative module from the applied live catalog. missing newly introduced module. - Resolver observability and same-path module-cache invalidation remain explicit design questions; neither weakens the containment and feature-gating contract. + +## Amendment — 2026-09-01 (Q39) + +Johannes approved Q39 to make WASIp2 Component Model components the universal +execution envelope for observable Resource providers. This amendment preserves +the original decision's closed core-wasm resolver and replaces only the +observable host-process mechanism described later by +[decision 0014](./0014-resource-profiles-are-state-first-read-and-observe-capabilities.md). +Decision 0014's state-first authority, demand semantics, typed `Publication`, +semantic filtering, and catch-up model remain in force. + +### Context + +The closed resolver has one pure job: map an opaque Resource URI to a contained +carrier path. It needs neither provider I/O nor the Component Model. Observable +providers have a different job: call a remote or local provider, normalize its +domain state, and propose a canonical publication. A catalog-trusted native +process can perform that job, but it carries ambient host authority and creates +a second long-lived lifecycle and JSON protocol beside Wasmtime. + +Five disposable prototypes tested a narrower boundary with Wasmtime 48.0.1. +Typed GitHub and PTY observations proved real domain I/O without exposing raw +HTTP, caller-selected executable/arguments, environment, filesystem, or socket +access. Fresh-Store cancellation tests ended with zero active tasks or +capabilities. Verified compiled-code reuse kept a small provider's AOT disk-hit +p50 at 0.482 ms and fresh Store plus instance observation p50 at 47.751 µs. An +independent multiprocess oracle passed three 30-process runs covering +one-winner compare-and-swap, stale generation, process-crash boundaries, +acknowledgement loss, deterministic outbox identity, and restart catch-up. + +### Decision + +1. The core-wasm resolver ABI, its no-import sandbox, fresh resolution Store, + path containment, registry behavior, feature gate, and transactional module + ownership remain the only resolution mechanism. +2. Every observable provider executes through one versioned WASIp2 Component + Model envelope. st2 does not maintain a parallel native or host-process + provider framework. +3. A provider component may import only explicit provider-domain capabilities + linked by the host. The host owns credentials, allowlists, limits, + deadlines, cancellation, and redacted typed failures. No ambient WASI + command, environment, clock, random, process, raw HTTP, filesystem, or + socket authority is linked. +4. Every descriptor call and observation receives a fresh Store and component + instance. Engine, Linker, compiled Component, and compatible host-produced + AOT bytes may be reused; Store and instance state may not be pooled or reset. +5. The component returns `Unchanged`, a typed failure, or the existing + `Publication` payload. It never writes the carrier, state record, receipt, or + outbox. The host pairs a publication with + `ProposalFence { generation, revision, prior_digest }`, validates it, and + owns the only atomic commit. +6. One durable transition makes the carrier, resulting digest and revision, + freshness and catch-up state, and deterministic `PublicationIntent` visible + together. A crash before publication leaves the old state. A crash after + publication but before acknowledgement leaves the intent retryable; + delivery remains separate and idempotent. +7. WASIp3 production execution, Store pooling, generic exec/raw + HTTP/filesystem/socket authority, a parallel native provider framework, and + runtime WAC graphs are explicit non-goals. Each requires new evidence and a + further accepted decision rather than an alternate dormant path. + +### Evidence and argument + +The durable record is the +[WASIp2 component and atomic publication experiment](../07-resource-profile/.experiments/2026-09-01-wasip2-component-and-atomic-publication-prototypes.md). +The disposable harnesses prove the boundary; their local source, result, cache, +and state paths are not production interfaces or scaffolding. + +The Component Model is selected for observable providers because its typed +imports make authority reviewable at link time and its typed export preserves +one observation result. Fresh Stores remove the need for an incomplete reset +protocol across guest memory, resources, host state, traps, and cancellation. +Host-only commit prevents a network- or command-capable guest from bypassing +validation or racing publication against settlement. The deterministic durable +outbox makes publication and delivery intent one crash-consistent fact without +claiming exactly-once effects across an external sink. + +### Consequences + +- A profile may carry two wasm artifacts with deliberately disjoint jobs: a + closed core module for resolution and, only when observable, a WASIp2 + component for provider observation. +- Provider support requires a reviewed typed host capability; adding a generic + authority under a domain-flavored name is non-conforming. +- Domain acquisition state such as ETags, cursors, rate limits, and webhook + repair cannot rely on guest Store lifetime. It belongs in bounded host-owned + capability state or explicit durable provider state. +- Compiled-code caching is an optimization, not an authority transfer. Any AOT + deserialize path must authenticate exact host-produced bytes and the complete + engine-compatibility key before crossing Wasmtime's unsafe boundary. +- Decision 0014's host-process topology and newline-delimited JSON mechanism + are superseded. Its publication, demand, delivery, and state-first semantics + apply to direct component invocations. diff --git a/docs/vrs/07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md b/docs/vrs/07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md index 3374992e..91d80afa 100644 --- a/docs/vrs/07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md +++ b/docs/vrs/07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md @@ -65,3 +65,13 @@ Two complexity reductions survive the prototypes: - Remove `reconcile` and `shutdown` from the normative runtime protocol. - Specify EOF as runtime termination and supervisor lifecycle as the only shutdown authority. - Keep restart/backoff policy in existing task lifecycle machinery rather than the profile protocol. + +## Subsequent evidence + +Q39 retains the selector round-trip and directional-fencing findings as +evidence, but supersedes the executable JSON-line runtime, including its +process-EOF lifecycle, as the observable-provider mechanism. The +[WASIp2 component and atomic publication prototypes](./2026-09-01-wasip2-component-and-atomic-publication-prototypes.md) +proved a narrower universal envelope: one fresh Store and component invocation, +domain-typed host capabilities, and a generation/revision/prior-digest proposal +fence. No production path or protocol depends on the disposable runtime driver. diff --git a/docs/vrs/07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md b/docs/vrs/07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md index 957979a9..c335c42c 100644 --- a/docs/vrs/07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md +++ b/docs/vrs/07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md @@ -61,3 +61,12 @@ The same lifecycle reducer is independent of shared versus per-binding runtime t - Reuse existing event supersession and DING transport for thin invalidations. - Keep provider cursors, webhook delivery identifiers, polling intervals, and observation repair inside the profile implementation. - Specify shared and per-binding runtimes behind one normalized host protocol; do not duplicate delivery state machines. + +## Subsequent evidence + +Q39 preserves the topology-independent catch-up reducer but supersedes the +shared/per-binding host-process mechanism in the original VRS impact. All +observable providers now use one fresh-Store WASIp2 component invocation. +Provider cursors and repair state remain outside the guest Store in host-owned +domain capability state or explicit durable provider state. The delivery +reducer remains independent of how those typed capabilities acquire input. diff --git a/docs/vrs/07-resource-profile/.experiments/2026-09-01-wasip2-component-and-atomic-publication-prototypes.md b/docs/vrs/07-resource-profile/.experiments/2026-09-01-wasip2-component-and-atomic-publication-prototypes.md new file mode 100644 index 00000000..824615ca --- /dev/null +++ b/docs/vrs/07-resource-profile/.experiments/2026-09-01-wasip2-component-and-atomic-publication-prototypes.md @@ -0,0 +1,83 @@ +# WASIp2 component and atomic publication prototypes + +Date: 2026-09-01 + +## Question + +Can observable Resource providers run through one capability-safe WASIp2 Component Model envelope while the host retains validation, fencing, atomic publication, and delivery ownership? + +## Method + +Five disposable prototypes exercised independent parts of the boundary with Wasmtime 48.0.1: + +1. A GitHub Issue component imported one typed issue-source operation and exported one typed observation operation. The host enforced an exact HTTPS host, port, and path allowlist; denied redirects and non-allowlisted or loopback destinations before I/O; bounded headers and bodies; and applied connection and operation deadlines. +2. A local-observation component imported one versioned, domain-typed `pty-stats` command. The host selected the executable and fixed arguments, passed no environment, used `/` as the child working directory, bounded retained stdout and stderr, enforced a deadline, and returned typed denials and failures. The guest could not supply an executable, path, environment, working directory, argument vector, or shell text. +3. A component lifecycle benchmark compared compilation, verified AOT deserialization, fresh Store plus instance invocation, and state reuse with recorded in-process provider input. +4. A cancellation and fencing driver exercised non-yielding guest CPU, a pending async host capability, concurrent same-prior proposals, stale generation after replacement, cancellation after guest return but before commit, and acknowledgement loss after commit. +5. A runtime-neutral multiprocess publication model used an independent oracle to verify fenced compare-and-swap, deterministic outbox identity, process-crash boundaries, retry, and delivery catch-up. + +The harnesses and their local artifact paths were disposable experiment machinery. They are provenance for the results below, not production modules, cache locations, state formats, or reusable runtime scaffolding. + +## Evidence + +### Typed component and capability boundary + +- The real GitHub request returned HTTP 200 and committed one carrier plus one event intent. A cold second process sent the prior ETag, received HTTP 304, and committed neither a duplicate carrier nor a duplicate event. +- The GitHub capability exposed one typed `get-issue(issue-key)` operation. It did not expose raw HTTP. The host applied a 16 KiB response-header ceiling, 64 KiB response-body ceiling, a 3 s connect deadline, a 10 s operation deadline, and redacted typed errors. +- The real `pty stats --json` observation used one fresh Store and exactly one typed host call. Its allowlist entry selected one fixed executable and four fixed arguments. Five denied or failed observations left committed bytes unchanged. +- Unknown tool IDs and unsupported typed argument variants failed before spawn. The child received zero environment entries, retained output was capped at 64 KiB per stream, and deadline cancellation targeted and reaped the process group. +- Both components returned normalized proposals only. The host performed proposal validation and the only state commit. + +### Fresh Store and compiled-code reuse + +- Every measured observation received new guest memory, tables, resources, and host state. A reused Store retained guest and host state and had no complete reset contract for deadlines, cancellation, async resources, or post-trap state. +- Cancellation verification created and dropped seven Stores. After guest interruption, host-future cancellation, stale fencing, commit cancellation, and acknowledgement-loss retry, active task and capability counts were zero. +- The measured verified AOT disk-hit p50 was 0.482 ms, and fresh Store plus instance observation p50 was 47.751 µs for the small recorded GitHub component. These measurements establish that fresh-Store isolation is viable for this fixture; they are not a cross-provider latency promise. +- AOT deserialization remained an unsafe native-code trust boundary. Corrupted artifacts were rejected before deserialization, and cache identity covered the component digest, Wasmtime/runtime build, target, and complete engine-compatibility hash. + +### Atomic proposal and delivery boundary + +- Concurrent observations with the same prior state produced exactly one compare-and-swap winner and one event intent. A stale generation after replacement committed nothing. +- Cancellation after component return but before host commit committed nothing. Acknowledgement loss after commit retried idempotently and did not duplicate carrier or event intent. +- The independent multiprocess oracle passed three runs of 30 processes. It recomputed carrier, event, and receipt identities from canonical durable bytes rather than importing the implementation. +- A crash before atomic publication preserved the old authoritative state. A crash after publication but before acknowledgement exposed the new carrier and deterministic outbox intent; restart delivery caught up and duplicate retry converged on the same identity. +- The filesystem experiment assumed POSIX lock exclusion, same-filesystem atomic rename visibility, file `fsync` before rename, and parent-directory `fsync` for durability. It did not establish power-loss behavior, remote-filesystem behavior, Windows behavior, or storage-controller honesty. + +## Result + +The evidence supports one production boundary: + +```text +fresh Store + one WASIp2 provider component invocation + | + +-- only domain-typed host capabilities + | + v +Unchanged | typed failure | Publication proposal + | + v +host validates ProposalFence + Publication + | + v +one atomic carrier + revision + deterministic outbox-intent transition + | + v +separate idempotent delivery and catch-up +``` + +Compiled components and compatible host-owned AOT artifacts may be reused. Stores and instances may not. Capability implementations and atomic commit remain trusted host code; component sandboxing does not sandbox the host process or make a generic subprocess safe. + +## Conclusion + +A WASIp2 component is a suitable universal execution envelope for observable providers when its imports are narrow domain interfaces and every observation receives a fresh Store. The host must remain the sole authority for credentials, capability policy, validation, fencing, canonical digest, atomic carrier publication, deterministic outbox intent, and delivery acknowledgement. + +The evidence does not support WASIp3 production execution, Store pooling, generic exec, raw HTTP, arbitrary filesystem or socket access, a parallel native-provider lifecycle, or a runtime WAC graph. Each would enlarge or duplicate the proven boundary and requires its own evidence gate. + +## VRS Impact + +- The closed core-wasm resolver remains the identity-to-carrier mechanism. +- One directly linked WASIp2 provider component envelope supersedes the trusted host-process observable runtime. +- Each descriptor call or observation receives one fresh Store and instance. +- `Publication` remains the observation payload and is paired with a host-owned generation, revision, and prior-digest fence. +- One host transition commits the carrier and deterministic durable outbox intent; delivery and acknowledgement remain separate. +- Generic host authorities, parallel provider runtimes, WASIp3 production, Store pooling, and runtime component graphs remain outside the contract until their explicit evidence gates are met. diff --git a/docs/vrs/07-resource-profile/ontology.md b/docs/vrs/07-resource-profile/ontology.md new file mode 100644 index 00000000..7ea2bb08 --- /dev/null +++ b/docs/vrs/07-resource-profile/ontology.md @@ -0,0 +1,66 @@ +# Resource Profile language + +## Language + +**Resource Profile:** The downstream-owned contract registered for one exact Resource URI scheme. It may resolve a Resource to a contained carrier and may additionally make that Resource observable. + +**resolver:** The closed, pure core-wasm part of a Resource Profile that maps a preserved Resource URI and agent directory to a contained carrier denotation. A resolver has no provider or ambient host authority. + +**observable provider:** The part of a Resource Profile that reads provider state, normalizes it, and proposes a current snapshot. An observable provider is not a resolver, delivery sink, or provider action API. + +**provider component:** The WASIp2 Component Model artifact that implements an observable provider through the universal provider world. It receives a fresh execution context for each descriptor call and observation. + +**provider world:** The versioned Component Model execution envelope shared by observable providers. It defines the descriptor and observation exports and admits only reviewed domain-capability imports. + +**domain capability:** A typed host operation for one provider-domain action, including its authority, inputs, outputs, limits, and failure vocabulary. _Avoid_: generic command, raw HTTP, arbitrary filesystem, raw socket. + +**ambient authority:** Host access available without a specific typed grant for the observation being performed. Environment inheritance, caller-selected processes, raw network clients, arbitrary paths, and raw sockets are ambient authority in this subsystem. + +**observation:** One attempt to determine a binding's current provider state. Its result is unchanged, a typed failure, or a proposed `Publication`; it is not itself a state transition. + +**ProposalFence:** The host-issued expected state for one proposal: binding generation, revision, and prior carrier digest. It prevents replaced bindings and competing observations from publishing over a different current state. + +**generation:** The identity of the current active binding registration. Replacement or unregister/register creates a different generation. + +**revision:** The host-owned monotonic publication version within one binding generation. + +**prior digest:** The authoritative carrier digest the observation was based on. It is a compare-and-swap precondition, not a component assertion about current host state. + +**Publication:** The bounded provider payload proposed as canonical current state: schema identity, media type, snapshot bytes, semantic topics, and optional ordered typed facts. It carries no publication authority. + +**publication proposal:** A `Publication` paired by the host with the `ProposalFence` under which it was observed. The proposal is input to validation and commit, not durable current state by itself. + +**PublicationIntent:** The deterministic durable outbox entry derived by the host from an accepted proposal. It identifies the committed publication and retains the selected semantic envelope required for delivery and catch-up. + +**atomic publication:** The single host-owned transition that makes the new carrier and its publication metadata, resulting revision, and `PublicationIntent` visible together. Readers observe either the prior complete state or the successor complete state. + +**carrier:** The contained local snapshot through which an agent reads the Resource's current canonical bytes. + +**delivery:** The separate, idempotent attempt to convey a committed `PublicationIntent` through the built-in resync stream. Delivery does not determine whether publication committed. + +**delivery acknowledgement:** Durable evidence that the sink accepted a `PublicationIntent`. Until acknowledgement, the intent remains eligible for retry. + +**catch-up:** Level-triggered delivery of the latest relevant committed state after delivery was unavailable. Catch-up does not replay every provider transition. + +## Structure + +```text +Resource Profile + ├─ resolver ──denotes──> carrier + └─ observable provider + └─ provider component + ├─ imports ──> domain capability + └─ observation + └─ proposes ──> Publication + +host pairs Publication + ProposalFence + └─ validates + └─ atomic publication + ├─ replaces ──> carrier + ├─ advances ──> revision + └─ creates ──> PublicationIntent + └─ delivery ──> delivery acknowledgement + └─ on interruption: catch-up +``` + +The leitwort is **propose, then commit**. Components observe and propose; only the host validates, fences, publishes, and delivers. diff --git a/docs/vrs/07-resource-profile/open-questions.md b/docs/vrs/07-resource-profile/open-questions.md index db43114a..33c8f9df 100644 --- a/docs/vrs/07-resource-profile/open-questions.md +++ b/docs/vrs/07-resource-profile/open-questions.md @@ -1,34 +1,49 @@ # Resource Profile open questions -The resolver registry, wasm-only pure module boundary, transactional ownership -of catalog-relative modules, state-first publication authority, typed semantic -facts, and atomic demand-observation result are accepted and therefore are not -open questions. Demand is explicitly capability-gated; fenced by owner, -registration, and watermark; coalesced to one in-flight plus one trailing -dispatch; and not cancelled by client wait expiry. +The resolver registry, closed core-wasm resolution boundary, transactional +ownership of catalog-relative wasm artifacts, state-first snapshot authority, +universal WASIp2 observable-provider envelope, domain-typed capability rule, +fresh Store per call, host-only fenced proposal commit, deterministic durable +outbox intent, typed semantic facts, and atomic demand result are accepted and +therefore are not open questions. + +The runtime-neutral proposal/publication foundation and the component executor +are separate conformance layers with one commit contract. This separation does +not create a native-provider alternative: publications from any source cross +the same `ProposalFence` and host-owned transition, while observable provider +execution conforms only through the component world. DQ-P3 is resolved by the raw JSON `selector` property and its round-trip prototype. DQ-P4 is resolved by treating initial readable state as a relevant state transition when the publication names a selected topic. DQ-P5 is resolved -by explicit 16 KiB selector, 2 MiB protocol-line, 1 MiB snapshot, 16 KiB health -detail and failed-result diagnostic, and typed-fact bounds. Representative st2 -issue and pull payloads remained below 41 KiB per item, leaving substantial -space for normalized reviews and check state without permitting unbounded -allocation. DQ-P6 is resolved by one directional owner claim per runtime -incarnation, one token per binding registration, EOF-owned termination, and the -shared ownership reducer. Evidence for selector and runtime ownership is in the -[selector and runtime protocol experiment](./.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md). +by explicit selector, snapshot, diagnostic, and typed-fact bounds plus +domain-interface-specific request, response, concurrency, and deadline bounds. +DQ-P6 is resolved by fresh per-call execution and +`ProposalFence { generation, revision, prior_digest }`; the earlier +host-process owner claim, registration token, topology, and JSON-line protocol +are superseded mechanisms. Evidence is recorded in the +[selector experiment](./.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md) +and the +[WASIp2 component and atomic publication experiments](./.experiments/2026-09-01-wasip2-component-and-atomic-publication-prototypes.md). -- **DQ-P1 ABI compatibility.** Descriptor ABI 3 makes version selection - explicit, but old-module/new-host, new-module/old-host, and runtime-protocol - compatibility are not yet proven. Resolve before independently released - third-party modules or runtimes with a compatibility matrix, frozen fixtures, - and cross-version conformance tests. Tracked in the - [spec](./spec.md#design-questions). -- **DQ-P2 Runtime observability.** The design separates descriptor, selector, - runtime, observation, publication, and delivery health, but has no dogfood +- **DQ-P1 Component compatibility.** Exact WIT world and domain-interface + versions make selection explicit, but old-component/new-host, + new-component/old-host, capability-version, and AOT-cache compatibility are + not yet proven across independent releases. Resolve before independently + released third-party components or capabilities with frozen WIT fixtures, a + cross-version conformance matrix, and cache-key rejection fixtures. Tracked + in the [spec](./spec.md#design-questions). +- **DQ-P2 Provider observability.** The design separates component loading, + descriptor, selector, capability, observation, proposal validation, + publication, outbox, and delivery health, but has no operated-provider evidence for the minimum low-noise logs, spans, metrics, freshness display, - or operator commands. Resolve by operating one GitHub PR/issue profile and - proving that an operator can distinguish credential failure, provider outage, - runtime crash, invalid publication, stale snapshot, and undeliverable agent - without hot-path noise. + or operator commands. Resolve with one GitHub provider and prove that an + operator can distinguish credential failure, provider outage, component + failure, capability denial, invalid proposal, stale fence, stale snapshot, + and undeliverable agent without hot-path noise. + +WASIp3 production execution, Store pooling, generic exec/raw +HTTP/filesystem/socket authority, a parallel native provider framework, and +runtime WAC graphs are deliberate exclusions rather than open implementation +questions. Their evidence gates are normative in the +[spec](./spec.md#deliberate-exclusions-and-evidence-gates-profile-r21r25). diff --git a/docs/vrs/07-resource-profile/requirements.md b/docs/vrs/07-resource-profile/requirements.md index 73497381..65bca77a 100644 --- a/docs/vrs/07-resource-profile/requirements.md +++ b/docs/vrs/07-resource-profile/requirements.md @@ -9,8 +9,9 @@ that st2 can observe. It refines [`06-resync`](../06-resync/requirements.md) without moving scheme ownership into st2 or making successful resolution a condition of agent launch. -The accepted resolver registry, wasm boundary, and transactional ownership are -recorded in +The accepted resolver registry, closed core-wasm boundary, transactional +ownership, and the Q39 amendment separating observable WASIp2 providers from +resolution are recorded in [decision 0009](../.decisions/0009-resource-profiles-use-a-feature-gated-wasm-boundary.md). The state-first read-and-observe authority, atomic publication and demand result, typed semantic envelope, and latest-state catch-up are recorded in @@ -22,8 +23,10 @@ result, typed semantic envelope, and latest-state catch-up are recorded in meaning remain downstream-owned. st2 ships no built-in Resource profiles; an unregistered scheme stays opaque and unwatchable. - **PROFILE-A02 Catalog trust:** The catalog operator chooses which resolver - module owns a scheme and which notification class its results carry. The - module itself is untrusted computation and receives no ambient host access. + module owns a scheme, which optional provider component implements + observation, which exact domain capabilities it may import, and which + notification class its results carry. Both wasm artifacts are untrusted + computation and receive no ambient host access. - **PROFILE-A03 Local denotation:** A successful profile resolution denotes a path inside the bound agent's directory. It does not grant authority over the URI, establish remote access, or change agent/task lifecycle semantics. @@ -31,10 +34,11 @@ result, typed semantic envelope, and latest-state catch-up are recorded in current snapshot is authoritative. Notifications are invalidations, not a complete event log, and no consumer may require every provider transition. - **PROFILE-A05 Downstream observation semantics:** A profile implementation - owns provider authentication, observation, reconciliation, semantic topics, - typed-fact meaning, snapshot schema, and selector defaults. st2 owns the - generic lifecycle, validation, atomic publication and demand result, - coalescing, fencing, delivery, health, and containment contracts. + owns provider reconciliation, normalization, semantic topics, typed-fact + meaning, snapshot schema, selector defaults, and the authentication + requirements of its domain capabilities. The host owns credential material, + capability policy, generic lifecycle, validation, atomic publication and + demand result, coalescing, fencing, delivery, health, and containment. - **PROFILE-A06 Read-and-observe scope:** Read and observe do not mutate provider state or standardize actions. Comments, CI reruns, label changes, close, merge, approval, and other provider writes require a separate @@ -46,32 +50,41 @@ result, typed semantic envelope, and latest-state catch-up are recorded in and compile-time cost is accepted in builds that enable `wasm-resolver` so the sandbox complexity is absorbed once. Default builds retain the baseline dependency and binary surface. -- **PROFILE-T02 Owned guest ABI:** st2 owns core-wasm descriptor ABI 3 and its - compatibility burden. Avoiding WASI and the component model keeps the - capability surface closed, but ABI evolution must remain explicit. -- **PROFILE-T03 Stateless calls:** Successful compilations and unchanged - compilation failures share a bounded cache, while each successful resolution - receives a fresh store and instance. The extra instantiation cost is accepted - for state, fuel, and memory isolation between calls. +- **PROFILE-T02 Two typed wasm contracts:** st2 owns the closed core-wasm + resolver ABI and the versioned WASIp2 Component Model provider world. The + resolver remains deliberately smaller because it only maps identity to a + contained carrier; observable providers use the richer component type + system without gaining ambient authority. +- **PROFILE-T03 Fresh observation state:** Compiled resolver modules and + provider components may be cached, but each resolution, descriptor call, and + provider observation receives a fresh Store and instance. The instantiation + cost is accepted so guest memory, tables, resources, host state, fuel, + deadlines, cancellations, and traps cannot leak between calls. - **PROFILE-T04 Superset-biased leaf publication:** Whole-catalog apply may - leave an unreferenced new module after a crash. Ordered atomic leaf + leave an unreferenced new wasm artifact after a crash. Ordered atomic leaf publication is preferred over a multi-file swap because catalog readers are already fenced by the transaction marker and recovery can remove the harmless superset; the catalog declaration must never point to a missing new - catalog-owned module. -- **PROFILE-T05 Provider-native observation:** st2 does not require polling, - webhooks, or a hybrid. The profile implementation may use the most efficient - provider-native mechanism, accepting responsibility for convergence, - backpressure, rate limits, and any provider cursor or repair state. + catalog-owned artifact. +- **PROFILE-T05 Provider-domain capabilities:** st2 hosts explicit typed + provider capabilities rather than generic process, network, or filesystem + access. Adding a provider domain therefore requires a reviewed host + interface, but its authority, validation, limits, and errors remain visible + in the type boundary. - **PROFILE-T06 One snapshot rather than facets:** The contract rewrites one atomic profile-defined snapshot even when provider facets change independently. This avoids generation manifests, facet consistency, and retention machinery until measured payload or read costs justify them. - **PROFILE-T07 Schema execution:** Discovering profile capabilities, selector - vocabulary, defaults, and validation requires executing the same bounded - module chosen by the catalog. This keeps the contract and implementation - atomic at the cost of making descriptor execution part of validation. - + vocabulary, defaults, and validation requires instantiating the same bounded + component selected by the catalog. This keeps the contract and + implementation atomic at the cost of making descriptor execution part of + validation. +- **PROFILE-T08 One component per observation:** A provider runs as one + directly linked component invocation, not a long-lived process or runtime + composition graph. Provider-native caches, cursors, and webhook repair state + therefore live in host-owned domain capability state or durable provider + state rather than guest Store state. ## Requirements @@ -83,10 +96,12 @@ result, typed semantic envelope, and latest-state catch-up are recorded in returns either a contained local path with a notification class or a structured failure. The registry is injectable so catalogs can add or replace profiles without hard-coding downstream schemes into st2. -- **PROFILE-R02 Wasm-only profile mechanism:** Every declared Resource profile - names a wasm resolver module. Declarative path-template and host-exec resolver - tiers are not part of the foundation; a static mapping uses the same wasm - boundary as arbitrary logic. +- **PROFILE-R02 Wasm-only profile mechanisms:** Every declared Resource profile + names one closed core-wasm resolver module. A profile that declares observable + capabilities additionally names one WASIp2 Component Model provider. + Declarative path-template, host-exec, native-provider, and alternate runtime + tiers are not part of the boundary; static mapping uses the resolver and all + observable behavior uses the component envelope. - **PROFILE-R03 Exact, deny-by-default registry:** Lookup is by an exact RFC 3986 scheme beginning with an ASCII letter. Duplicate declarations, malformed declarations, unknown profile fields, and unsupported notification classes @@ -115,10 +130,12 @@ result, typed semantic envelope, and latest-state catch-up are recorded in ### Must preserve optionality and composition -- **PROFILE-R07 Feature isolation:** `wasm-resolver` is an opt-in build feature. - A default build carries no wasmtime dependency. It can still parse and retain - profile declarations, but attempting to resolve one reports that the feature - is unavailable rather than silently substituting another mechanism. +- **PROFILE-R07 Feature isolation:** `wasm-resolver` is an opt-in build feature + covering both the resolver engine and observable component executor. A + default build carries no wasmtime dependency. It can still parse and retain + profile declarations, but attempting to resolve or observe one reports that + the feature is unavailable rather than silently substituting another + mechanism. - **PROFILE-R08 Resync composition:** Successful resolution supplies both the local carrier path and its declared `immediate` or `coalesced` class to resync. Profile class takes precedence over basename heuristics; `silent` @@ -133,16 +150,16 @@ result, typed semantic envelope, and latest-state catch-up are recorded in ### Must transact catalog-owned modules -- **PROFILE-R10 Transactional module ownership:** A resolver module whose - declared path is catalog-relative is a first-class declaration input. - Snapshot, digest, diff, bootstrap, prepare, apply, and recovery include its - exact normalized path and bytes in the catalog projection and root hash. - Duplicate references to one normalized path contribute one input. A missing, - escaping, symlinked, special, or oversized catalog-owned module and an - unprojected prepared module fail validation before publication. Literal - absolute module paths remain external immutable inputs and are not copied - into catalog bundles. Publication orders new module bytes before the - `catalog.kdl` that names them and retires old module bytes only after that +- **PROFILE-R10 Transactional wasm ownership:** A resolver module or provider + component whose declared path is catalog-relative is a first-class + declaration input. Snapshot, digest, diff, bootstrap, prepare, apply, and + recovery include its exact normalized path and bytes in the catalog + projection and root hash. Duplicate references to one normalized path + contribute one input. A missing, escaping, symlinked, special, oversized, or + unprojected catalog-owned artifact fails validation before publication. + Literal absolute paths remain external immutable inputs and are not copied + into catalog bundles. Publication orders new artifact bytes before the + `catalog.kdl` that names them and retires old artifact bytes only after that declaration stops naming them. ### Must opt into chain notification @@ -156,13 +173,13 @@ result, typed semantic envelope, and latest-state catch-up are recorded in ### Must describe and validate observable capabilities -- **PROFILE-R12 Versioned profile descriptor:** A profile module exposes one - bounded descriptor in addition to resolution. Descriptor ABI 3 declares - supported capabilities, selector schema, semantic topic vocabulary, default - selector value, runtime topology, snapshot media type, and snapshot schema - identity. The host validates the descriptor under the same fuel, memory, - output, import, and failure isolation as resolution. Unknown required - capabilities or ABI versions fail that profile locally. +- **PROFILE-R12 Versioned component descriptor:** An observable provider + exposes one bounded, typed descriptor through the universal WASIp2 provider + world. It declares capabilities, selector schema, semantic topic vocabulary, + default selector value, snapshot media type, and snapshot schema identity. + The host validates the descriptor under the same fresh-Store, resource-bound, + no-ambient-authority, and failure-isolation policy as observation. Unknown + required capabilities or provider-world versions fail that profile locally. - **PROFILE-R13 Validated binding selectors:** An observable Resource binding may carry profile-specific selector configuration. Absence means the descriptor's default. KDL encodes the value as compact JSON in a `selector` @@ -173,70 +190,69 @@ result, typed semantic envelope, and latest-state catch-up are recorded in attention, never Resource URI identity, access authority, snapshot contents, or provider observation. -### Must publish one canonical current snapshot +### Must commit one fenced proposal atomically -- **PROFILE-R14 Atomic snapshot authority:** Each active observable binding has - at most one profile-defined canonical current snapshot. `Publication` is the - reusable payload for every publication form and contains schema identity, - media type, snapshot bytes, semantic topics, and optional ordered typed - facts. The host validates one complete `Publication`, computes its content - digest from accepted bytes, replaces the snapshot atomically, and never - exposes partial bytes. Periodic `Publish` and demand-result `Published` - traverse the same acceptance, digest, relevance, and catch-up core. Equal - bytes do not create a state transition. The first accepted publication with - at least one selected topic schedules the same superseding invalidation as a - later relevant change. The snapshot remains authoritative after missed, - duplicated, reordered, or coalesced provider observations. -- **PROFILE-R15 Implementation-owned observation:** A profile implementation - chooses polling, push, native subscription, or a hybrid and retains its own - provider mechanism, cursor, conditional cache, rate-limit state, backoff, and - repair policy. A generic demand may pull an eligible observation forward but - never selects the provider mechanism, resets provider state, or becomes a - provider-specific reconcile command. Provider payloads never bypass - `Publication` to become canonical delivery records, and demand observation - never authorizes a provider write. -- **PROFILE-R16 Declared runtime topology and fencing:** The descriptor declares - either one shared runtime per catalog and exact scheme or one runtime per - active binding. Both modes use one host protocol and per-binding lifecycle - state. Each runtime incarnation receives a directional owner claim; each - binding registration receives a token. The host accepts or addresses output - only while owner, binding, and registration match current state. EOF and the - supervisor process lifecycle own termination and restart. Shared-runtime - failure may affect observation for many bindings but reports health per - binding; per-binding failure remains local. -- **PROFILE-R16A Finite protocol and publication bounds:** A selector's - canonical compact JSON is at most 16 KiB. One encoded runtime-protocol line is - at most 2 MiB including its newline. Decoded snapshot bytes are at most 1 MiB. - Health detail and a failed demand diagnostic are each at most 16 KiB of +- **PROFILE-R14 Atomic proposal authority:** Each active observable binding has + at most one profile-defined canonical current snapshot. A component may + return `Unchanged`, a typed failure, or one complete `Publication`. For a + publication result the host constructs a proposal from that payload and the + invocation's `ProposalFence { generation, revision, prior_digest }`, validates + it, and alone owns the commit. One atomic transition makes the carrier, + current digest, resulting revision, freshness, semantic catch-up state, and + deterministic durable `PublicationIntent` visible together; the component + cannot write any of them. Equal accepted bytes do not create a state + transition. A crash before publication leaves the prior state authoritative. + A crash after publication but before delivery acknowledgement leaves the + outbox intent retryable. +- **PROFILE-R15 Domain-typed observation:** Every observable provider executes + through the same versioned WASIp2 Component Model envelope and may import + only host-linked, provider-domain capabilities explicitly declared for that + component. Host implementations own credentials, endpoint and operation + allowlists, response and concurrency bounds, deadlines, cancellation, and + redacted typed errors. The component owns provider normalization and may + propose canonical state; it receives no ambient WASI command, environment, + clock, random, process, raw HTTP, filesystem, or socket authority. +- **PROFILE-R16 Fresh-Store execution and fencing:** Every descriptor call and + observation creates a fresh Store and component instance, invokes it once, + validates the result, and drops the Store. The host may reuse an Engine, + Linker, and compiled component keyed by exact artifact and engine + compatibility, but never pools or resets Store or instance state. Every + proposal is fenced by the current binding generation, expected revision, and + prior carrier digest. The host rejects stale generation or stale prior state + without publication; concurrent proposals from one prior state have at most + one commit winner, and retry of the same deterministic proposal is + idempotent. +- **PROFILE-R16A Finite execution and proposal bounds:** A selector's canonical + compact JSON is at most 16 KiB. Decoded snapshot bytes are at most 1 MiB. + Health detail and a failed observation diagnostic are each at most 16 KiB of UTF-8. One `Publication` carries at most 32 ordered facts; each fact key is at most 128 bytes and each before/after value is at most 1 KiB of printable - single-line UTF-8. st2 rejects an oversized value without truncation and - contains the failure to the affected runtime or binding. + single-line UTF-8. Each domain capability defines request, response, + concurrency, and deadline bounds before it is linked. st2 rejects an + oversized or invalid value without truncation and contains the failure to + the affected observation or binding. - **PROFILE-R16B Declared atomic demand:** Demand observation is explicitly - declared and denied by default. Only a runtime declaration with the `demand` - capability may receive `Observe`. Each `Observe` carries a positive demand - watermark and current owner, binding, and registration fences. The runtime - answers exactly once for that demand with one correspondingly fenced - `ObservationResult`: `Unchanged`; `Failed` with an optional bounded - diagnostic; or `Published` with one complete `Publication`. There is no - separate demand publication and settlement, digest supplied by the runtime, - or protocol observation timestamp. -- **PROFILE-R16C Coalesced, non-cancelling demand:** For one active - registration, st2 keeps at most one demand dispatch in flight and one latest + declared and denied by default. Only a component descriptor with the + `demand` capability may receive a demand invocation. Each invocation carries + a positive demand watermark and a + `ProposalFence { generation, revision, prior_digest }`. It returns exactly + once with `Unchanged`; a bounded typed failure; or one `Publication`. There + is no separate publication and settlement, digest supplied as authority by + the component, or protocol observation timestamp. +- **PROFILE-R16C Coalesced, non-cancelling demand:** For one active binding + generation, st2 keeps at most one demand invocation in flight and one latest trailing watermark. Demand accepted during an in-flight observation survives - its result and coalesces into the trailing dispatch. Only an exact atomic - result, replacement of its fenced registration, or provider-process failure - closes accepted work; no clock participates in correctness. `Published` - settles as `settledChanged` with the host-computed accepted-publication - digest, including when equal bytes create no state transition or resync - delivery emission fails after the snapshot and catch-up transaction commits. - A missing active binding maps to `absentBinding`; a binding whose runtime did - not declare demand also maps to `absentBinding` with the explicit diagnostic - `the profile runtime does not declare the demand capability`. A client - generation older than the resident supervisor maps to `staleGeneration`; a - newer generation remains queued until supervisor refresh. Provider failure - maps to `providerUnavailable`. Client disconnect or wait expiry does not - cancel accepted work, retract it, or alter the runtime's observation schedule. + its result and coalesces into the trailing invocation. Only an exact atomic + result, replacement of its fenced generation, or executor failure closes + accepted work; no clock participates in correctness. `Committed` settles as + `settledChanged` with the host-computed accepted-publication digest, including + when resync delivery fails after the carrier and outbox intent commit; + proposal-commit `Unchanged` settles as `settledUnchanged`. A missing active + binding or one without demand maps to `absentBinding`; an older client + generation maps to `staleGeneration`; a newer generation remains queued + until supervisor refresh; provider failure maps to `providerUnavailable`. + Client disconnect or wait expiry does not cancel accepted work, retract it, + or alter observation scheduling. - **PROFILE-R16D Durable demand intent:** Observe request and receipt records carry the exact schema identities `st2.resource-observe-request.v1` and `st2.resource-observe-receipt.v1`. They are private to one supervisor scope @@ -246,11 +262,9 @@ result, typed semantic envelope, and latest-state catch-up are recorded in the same cap. An admitted request record remains the durable, retryable intent until a terminal receipt is durably committed; only then may the request be removed. In-memory enqueue and nonterminal receipts do not - transfer that ownership. - A failed terminal receipt commit retains retryable state and - leaves the request eligible for restart. - Receipt status values use camelCase: `accepted` and `backpressured` are - nonterminal; + transfer that ownership. A failed terminal receipt commit retains retryable + state and leaves the request eligible for restart. Receipt status values use + camelCase: `accepted` and `backpressured` are nonterminal; `settledUnchanged`, `settledChanged`, `settledFailed`, `absentBinding`, `staleGeneration`, and `providerUnavailable` are terminal. Only `settledChanged` carries the host-computed digest of accepted publication @@ -260,16 +274,16 @@ result, typed semantic envelope, and latest-state catch-up are recorded in - **PROFILE-R17 Semantic invalidation:** Every Resource invalidation carries the same bounded ordered fact envelope in its durable body and renders at most - three whole facts into a subject of at most 96 Unicode scalars. Both periodic - and demand publications may supply facts and semantic topics in - `Publication`; st2 validates the facts, applies the binding selector to - topics, and retains the selected topics and facts through catch-up. Passive - carrier changes publish one `content` topic and a short digest-transition - fact. Agent Spec declaration changes publish ordered binding-label facts for - added, removed, and semantically changed Resource declarations without - exposing URIs or reasons; unavailable declaration parsing falls back to a - digest-transition fact rather than dropping the invalidation. Snapshot bytes - and provider payloads remain in the authoritative carrier, not the event. + three whole facts into a subject of at most 96 Unicode scalars. For a + published result, the host validates facts and topics, applies the binding + selector, and derives the deterministic `PublicationIntent` committed with + the carrier. Passive carrier changes publish one `content` topic and a short + digest-transition fact. Agent Spec declaration changes publish ordered + binding-label facts for added, removed, and semantically changed Resource + declarations without exposing URIs or reasons; unavailable declaration + parsing falls back to a digest-transition fact rather than dropping the + invalidation. Snapshot bytes and provider payloads remain in the + authoritative carrier, not the event. - **PROFILE-R18 Built-in superseding delivery:** Smart Resource invalidations reuse one built-in per-agent delivery stream and the existing inbox, DING, deduplication, and producer-side supersession machinery. The binding name is @@ -282,19 +296,40 @@ result, typed semantic envelope, and latest-state catch-up are recorded in pending historical digest. When delivery becomes available, pending relevant state emits at most one invalidation for the then-current snapshot digest with that retained semantic envelope. -- **PROFILE-R20 Observable health:** st2 reports descriptor, selector, - observation, reconciliation, publication, and delivery health separately. - Failure degrades only the affected profile runtime or binding, preserves the - last proven snapshot with explicit freshness, and never presents stale bytes - as newly observed state. +- **PROFILE-R20 Observable health:** st2 reports component loading, descriptor, + selector, capability, observation, proposal validation, publication, and + delivery health separately. Failure degrades only the affected profile or + binding, preserves the last proven snapshot with explicit freshness, and + never presents stale bytes as newly observed state. + +### Must keep the component authority narrow + +- **PROFILE-R21 WASIp2 production baseline:** Observable providers target the + WASIp2 Component Model. WASIp3 production execution is not part of this + contract. +- **PROFILE-R22 No Store pooling:** Provider Stores and instances are never + pooled, reused, or reset across observations. +- **PROFILE-R23 No generic host escape hatches:** The provider world exposes no + caller-selected executable or arguments, shell, raw HTTP client, arbitrary + filesystem path, or raw socket capability. Domain interfaces are not thin + aliases for those authorities. +- **PROFILE-R24 One provider framework:** st2 does not maintain a parallel + native or host-process provider framework. Observable profiles use the + component envelope. +- **PROFILE-R25 No runtime component graph:** The host links one provider + component directly to reviewed domain capabilities. Runtime WAC composition + and provider-selected component graphs are not part of execution. ## Evidence -The mechanism choice and sandbox bounds are supported by the +The closed resolver mechanism and sandbox bounds are supported by the [plugin-boundary comparison](./.experiments/2026-08-26-plugin-boundary-comparison.md). Composition against the real Nix-generated standing-seat shape is supported by the [real-shape end-to-end experiment](./.experiments/2026-08-26-dotfiles-real-shape-e2e.md). -The state-first attention boundary is supported by the -[GitHub attention-filter prototype](./.experiments/2026-08-29-github-attention-filter-prototype.md). -The minimal catch-up state and topology-independent lifecycle are supported by -the [smart Resource lifecycle state-space prototype](./.experiments/2026-08-29-smart-resource-lifecycle-prototype.md). +The state-first attention boundary and minimal catch-up state are supported by +the [GitHub attention-filter](./.experiments/2026-08-29-github-attention-filter-prototype.md) +and [lifecycle state-space](./.experiments/2026-08-29-smart-resource-lifecycle-prototype.md) +prototypes. The component envelope, domain-typed capabilities, fresh-Store +policy, cancellation fences, host-only commit, atomic outbox publication, and +separate idempotent delivery are supported by the +[WASIp2 component and atomic publication experiments](./.experiments/2026-09-01-wasip2-component-and-atomic-publication-prototypes.md). diff --git a/docs/vrs/07-resource-profile/spec.md b/docs/vrs/07-resource-profile/spec.md index d163bc73..2c99e277 100644 --- a/docs/vrs/07-resource-profile/spec.md +++ b/docs/vrs/07-resource-profile/spec.md @@ -1,34 +1,43 @@ # Resource Profile spec -This document specifies the Resource Profile registry, resolver SDK boundary, -wasm execution contract, observable runtime protocol, and state-first -publication authority. It builds on [`requirements.md`](./requirements.md). +This document specifies the Resource Profile registry, closed core-wasm +resolver, universal WASIp2 observable-provider envelope, and state-first +atomic publication authority. It builds on +[`requirements.md`](./requirements.md). + +## Status + +Draft. The runtime-neutral fenced proposal, atomic publication, and durable +outbox contract is the foundation shared by passive and component-produced +publications. Component-enabled conformance additionally requires the execution +and capability boundary below; a native runtime is not a fallback. ## Ownership and flow -This subsystem owns scheme-to-profile registration, the guest ABI, descriptor -and selector validation, sandbox budgets, host path containment, observable -runtime lifecycle, atomic periodic publication and demand results, and the -handoff of passive and observable carriers to -[`06-resync`](../06-resync/spec.md). It does not own Resource URI semantics, -provider authentication, provider observation strategy, provider mutation, -task launch, or a canonical provider event log. Those remain downstream -profile concerns or explicit non-goals. +This subsystem owns scheme-to-profile registration, both guest ABIs, descriptor +and selector validation, sandbox budgets, host path containment, provider +component lifecycle, host-only atomic proposal commit, and the handoff of +passive and observable carriers to [`06-resync`](../06-resync/spec.md). It does +not own Resource URI semantics, provider credentials, provider mutation, task +launch, a canonical provider event log, or ambient host access. Those remain +downstream semantics or explicit non-goals. + +## Architecture (PROFILE-R01..R25) -## Architecture (PROFILE-R01..R20) +Resolution remains a closed core-wasm operation: ```text Agent Spec resource URI (opaque, byte-preserved) | v exact RFC 3986 scheme /catalog.kdl - profile "" { wasm ""; class ""; notify-chain #true; } + profile "" { wasm ""; class ""; ... } | - +--> catalog-relative module -- normalized no-follow projection - | + catalog root hash / transaction + +--> catalog-relative resolver -- normalized no-follow projection + | + catalog root hash / transaction | v ResourceProfileRegistry (injectable; built-ins empty) - bounded outcome cache keyed by normalized module path + admission policy + file identity + bounded compiled-module cache | v fresh Store + Instance per resolution closed core-wasm guest (no imports / no WASI) @@ -40,25 +49,29 @@ Agent Spec resource URI (opaque, byte-preserved) resync watch set and existing event pipeline ``` -An observable profile extends the same contained carrier without changing URI -identity or introducing another delivery plane: +An observable profile adds one component artifact; it does not replace or +enlarge the resolver: ```text -closed wasm describe() -> capabilities + selector schema/default + topology - | -catalog-trusted host runtime argv ----+ - | - v provider-native observation -periodic Publish(Publication) or demanded ObservationResult - | - v one host validation + digest + atomic publication authority -canonical snapshot + current digest - | - v selector + pending-relevance reducer retaining topics + facts +WASIp2 provider component + export describe() + export observe(request) + import only reviewed domain capability interfaces + | + v fresh Store + Instance for one call +Unchanged | typed failure | Publication + | + v host pairs Publication with ProposalFence +validate generation + revision + prior digest + payload bounds + | + v one host-owned atomic transition +carrier + current digest/revision + freshness/catch-up + PublicationIntent + | + v separate idempotent delivery from durable outbox built-in resync event (key=binding, supersede=true) - | - v existing inbox + DING -agent rereads canonical snapshot + | + v existing inbox + DING +agent rereads canonical carrier ``` The SDK is a typed, trait-shaped boundary rather than a set of scheme-specific @@ -91,14 +104,19 @@ ProfileSource::Wasm { } ``` -`containment_root` is the trusted descriptor-traversal root for catalog-relative -modules and is absent for explicitly external absolute modules. +`containment_root` is the trusted descriptor-traversal root for +catalog-relative resolver modules and is absent for explicitly external +absolute modules. Observable component configuration is separate from +`ProfileSource`: it cannot participate in path resolution or change +`ProfileClass`. There is no template or exec variant. `ResourceProfileRegistry::builtin()` is empty. `with_profile` and `with_profiles` inject catalog-owned registrations; a later programmatic insertion for the same exact scheme replaces the prior entry, while duplicate schemes in one catalog declaration are rejected before -registry construction. +registry construction. Observable execution is present only when the same +profile also declares one component and its exact allowed WIT capability +interfaces. ## Catalog declaration (PROFILE-R02..R03) @@ -115,13 +133,22 @@ profile "dev.schickling.agent-goal" { class "immediate" notify-chain #true } + +profile "github-issue" { + wasm "resolvers/github-issue.wasm" + component "providers/github-issue.component.wasm" + capability "st2:github-issue/source@1.0.0" + class "coalesced" +} ``` Grammar: ```text profile { # exactly one positional value; no properties - wasm # exactly once + wasm # exactly once; closed resolver + component # zero or one; observable provider + capability # zero or more; requires component class immediate|coalesced|silent # zero or one; default coalesced notify-chain # zero or one; default false } @@ -129,21 +156,27 @@ profile { # exactly one positional value; no propertie The profile scheme follows RFC 3986: it begins with an ASCII letter, then accepts ASCII alphanumeric characters plus `+`, `-`, and `.`, and rejects `/`; -lookup remains exact and case-sensitive. The profile -node takes exactly one quoted positional scheme and no properties. `wasm` and -`class` each take one quoted positional value; `notify-chain` takes one boolean. -Unknown or extra entries, unknown children, duplicate children, a missing -`wasm`, unsupported class values, and duplicate profile schemes fail parsing. -A literal absolute module path remains an external runtime input. Every other -declaration expands `$CATALOG` and environment variables, resolves lexically -against the catalog root, and must remain strictly beneath that root; internal -`.`/`..` components normalize away, while traversal outside the root fails -validation. - -`st2 validate` reports malformed declarations and missing or unsafe -catalog-relative modules. `st2 up` loads declared profiles before it spawns -tasks, so a malformed profile block fails loudly rather than silently removing -watch coverage. +lookup remains exact and case-sensitive. The profile node takes exactly one +quoted positional scheme and no properties. `wasm`, `component`, and `class` +each take one quoted positional value; `notify-chain` takes one boolean and +each `capability` takes one exact versioned WIT interface ID. Capability IDs +are owned by their WIT package namespace; the host compares the canonical +package, interface, and complete semantic version, not a display alias. +Duplicate capability IDs, capability without component, unknown or extra +entries, duplicate singleton children, missing `wasm`, unsupported class +values, and duplicate profile schemes fail parsing. + +A literal absolute wasm artifact path remains an external runtime input. Every +other resolver or component declaration expands `$CATALOG` and environment +variables, resolves lexically against the catalog root, and must remain +strictly beneath that root; internal `.`/`..` components normalize away, while +traversal outside the root fails validation. + +`st2 validate` reports malformed declarations, missing or unsafe +catalog-relative artifacts, provider-world mismatches, and component imports +outside the profile's exact capability set. `st2 up` loads declared profiles +before it spawns tasks, so an invalid profile fails loudly rather than silently +removing watch or observation coverage. The scheme namespace remains downstream-owned. A private profile uses its owner's reverse-domain scheme (for example `dev.schickling.agent-goal`); st2 @@ -163,34 +196,34 @@ path. Representative behavior: ```text exact root catalog.kdl | - v parse + expand each non-absolute wasm path + v parse + expand each non-absolute wasm/component path lexically normalized path strictly below catalog root | v descriptor-relative O_NOFOLLOW open -regular module <= 16 MiB +regular bounded artifact | v deduplicate by normalized relative path catalog transaction projection + declaration-root hash | v snapshot / digest / diff / bootstrap / apply / recovery -prepared bundle and live catalog contain the same catalog.kdl + module bytes +prepared bundle and live catalog contain the same declaration + artifact bytes ``` The whole-catalog projector parses only the exact `catalog.kdl` at its -projection root. Each catalog-relative module is opened from a retained root -capability: every ancestor is a no-follow directory and the final no-follow, -nonblocking descriptor must be a regular file no larger than the runtime's -16 MiB module cap. Missing files, symlinked ancestors or leaves, FIFOs and -other special files, paths escaping the root, and oversized modules reject the -transaction and `st2 validate`. Two profiles whose paths normalize to the same -relative path contribute one projected entry. Any file present in a prepared -catalog but absent from this closed projection remains an unprojected-input -error. - -A literal absolute `wasm` path is external and immutable from the catalog -transaction's perspective. Its bytes are neither copied nor hashed, even when -the literal happens to name a file physically below the live catalog. A -non-absolute declaration that expands through `$CATALOG` or an environment +projection root. Each catalog-relative resolver module and provider component +is opened from a retained root capability: every ancestor is a no-follow +directory and the final no-follow, nonblocking descriptor must be a regular +file within its admission bound. Missing files, symlinked ancestors or leaves, +FIFOs and other special files, paths escaping the root, and oversized artifacts +reject the transaction and `st2 validate`. References whose paths normalize to +the same relative path contribute one projected entry. Any file present in a +prepared catalog but absent from this closed projection remains an +unprojected-input error. + +A literal absolute `wasm` or `component` path is external and immutable from +the catalog transaction's perspective. Its bytes are neither copied nor hashed, +even when the literal happens to name a file physically below the live catalog. +A non-absolute declaration that expands through `$CATALOG` or an environment variable is catalog-owned and must still normalize beneath the logical catalog root; expansion cannot turn a relative declaration into an escape hatch. @@ -198,10 +231,10 @@ Apply publishes new or changed projected inputs before atomically replacing `catalog.kdl`, then removes stale projected inputs after the declaration no longer names them. The incomplete-apply marker fences cooperating catalog readers throughout this sequence. The crash bias is a safe superset: failure -before the declaration replacement may leave an unreferenced new module, and -failure after it may leave an unreferenced old module, but the live -`catalog.kdl` never names a missing newly published module. Durable-stage -recovery repeats the same ordering and removes the superset. +before declaration replacement may leave an unreferenced new artifact, and +failure after it may leave an unreferenced old artifact, but live `catalog.kdl` +never names a missing newly published artifact. Durable-stage recovery repeats +the same ordering and removes the superset. ## Core wasm ABI (PROFILE-R04..R05) @@ -261,15 +294,14 @@ authorize a contained declaration. Registry clones and concurrent subscribers coalesce one compilation attempt only for an unchanged identity under the same policy. Byte replacement or metadata identity change invalidates that entry. Each resolution of a successfully compiled module creates a fresh `Store` and -`Instance`. One fuel allowance -covers the module start function and the first resolution call; a reused -instance receives one fresh allowance before each later call: +`Instance`. One fuel allowance covers the module start function and its single +resolution call; the instance is then discarded. | Boundary | Contract | | --- | --- | | Module file | regular, nonblocking; catalog-relative paths use descriptor-relative no-follow traversal for every component; 16 MiB maximum before Wasmtime compilation | | Imports | none; import-requiring modules fail instantiation | -| Fuel | 5,000,000 fuel units for start + first call; same budget per later call | +| Fuel | 5,000,000 fuel units for start + the single resolution call | | Linear memory | 64 MiB maximum | | Resolver return | memory range must be valid and at most 64 KiB before UTF-8/JSON decoding | | Memories | at most 1 | @@ -335,302 +367,329 @@ agent-local behavior above. Profile resolution is observation metadata only and never enters task launch targets. -## Observable profile descriptor (PROFILE-R12..R13) +## Observable provider world (PROFILE-R12..R13) -An observable profile retains the resolver ABI and adds one bounded descriptor -export. The descriptor is the single source of truth for the profile contract: +An observable profile retains the closed resolver and adds one component that +conforms to the repository-owned +`st2:resource-provider/provider@0.1.0` WASIp2 world. The package and interface +names are lowercase ASCII WIT identifiers and the complete semantic version is +part of compatibility. A host accepts only an exact supported world; it does +not guess across unknown major, minor, or patch versions. -```text -describe() -> packed(ptr, len) -``` +The world has two host-called exports: -The returned UTF-8 JSON uses the same 64 KiB output bound, pointer checks, -fresh-instance policy, fuel budget, and no-import rule as `resolve`: +```text +describe() -> result + +observe(ObserveRequest { + uri, + selector, + prior_digest?, + demand_watermark? +}) -> ObservationResult { + Unchanged + | Failed { diagnostic? } + | Published { publication: Publication } +} -```json -{ - "abiVersion": 3, - "capabilities": ["resolve", "read", "observe"], - "selectorSchema": { - "type": "object", - "properties": { - "topics": { - "type": "array", - "items": { "type": "string" }, - "uniqueItems": true - } - }, - "additionalProperties": false - }, - "defaultSelector": { - "topics": ["ci.failure", "mergeability.conflict", "review.requested"] - }, - "topics": [ - { "name": "ci.failure" }, - { "name": "ci.success" }, - { "name": "mergeability.conflict" }, - { "name": "review.requested" } - ], - "runtime": { "topology": "shared" }, - "snapshot": { - "mediaType": "application/json", - "schemaId": "dev.example.github-pr.snapshot.v1" - } +Publication { + schema_id, + media_type, + bytes, + topics, + facts? } ``` -`abiVersion` governs the complete descriptor and host protocol. Capabilities -are closed strings known by that ABI version; ABI 3 accepts `resolve`, `read`, -and `observe`. `topics[].name` values are unique, non-empty profile-owned -identifiers. `defaultSelector` must validate against `selectorSchema` and name -only published topics. A binding selector is validated against the same schema -and topic set before registration. Selector configuration is observation -metadata: it is not part of the Resource URI and cannot change resolution, -snapshot bytes, credentials, or provider access. +`ProviderDescriptor` declares supported scheduling capabilities, selector +schema, default selector, published semantic topics, snapshot media type, and +snapshot schema identity. The typed provider world replaces descriptor ABI 3's +packed core-wasm JSON export and the host-process JSON protocol. It does not +replace the resolver ABI. + +The host calls `describe` in a fresh Store under descriptor phase policy. +Operational domain capabilities return a typed phase denial if the component +attempts to call them while describing itself. The descriptor is bounded and +fully validated before any binding is activated. Unknown required +capabilities, duplicate or empty topic names, an invalid selector schema or +default, mismatched snapshot identity, and an unsupported world version fail +only that profile. -Agent Spec KDL carries the normalized selector JSON as a `selector` raw-string -property on the Resource node: +Agent Spec KDL carries normalized selector JSON as a `selector` raw-string +property: ```kdl resource "pr" uri="github-pr://example/1" reason="Review." \ selector=#"{"topics":["ci.failure","review.requested"]}"# ``` -The canonical renderer serializes normalized compact JSON and chooses the +The canonical renderer serializes compact normalized JSON and chooses the smallest raw-string hash fence whose closing delimiter does not occur in the payload. JSON and TOML Agent Spec forms carry the selector as a native JSON value. All forms lower to the same `serde_json::Value`; KDL spelling is not -preserved and cannot change selector semantics. +preserved. The selected value must validate against the descriptor schema and +may name only published topics. It cannot change resolution, snapshot bytes, +credentials, linked capabilities, or provider authority. -## Observable runtime declaration and protocol (PROFILE-R15..R16D) +## Component execution and capabilities (PROFILE-R15..R16A, R21..R25) -The closed wasm module never receives network, credential, filesystem, process, -or clock imports. A profile with `observe` therefore also has one -catalog-trusted host runtime declaration: +```text +host scheduler + | + v load exact catalog-selected Component +reuse Engine + Linker + compiled Component when cache identity matches + | + v create fresh Store +link only catalog-approved domain WIT interfaces + | + v instantiate once; describe or observe once + | + v validate typed result; cancel outstanding capability work +drop instance + Store +``` -```kdl -profile "github-pr" { - wasm "resolvers/github-pr.wasm" - class "coalesced" - runtime { - argv "github-resource-runtime" "pr" - capability "demand" - } +The host creates a new `Store` and component instance for every +descriptor call and observation. No Store, instance, guest memory, table, +resource handle, host call counter, deadline state, fuel state, or cancellation +state survives the call. An observation invokes `observe` exactly once. + +One process may retain an Engine, Linker, and compiled Component. Cache identity +includes the exact component SHA-256, Wasmtime/runtime build identity, target +triple, and a fingerprint of every compilation-relevant Engine setting. A +host-produced AOT artifact is deserialized only after its length and SHA-256 +match an authenticated host-owned manifest and that complete compatibility key. +Any mismatch is a cache miss. Guest-supplied precompiled bytes never cross the +unsafe deserialization boundary. + +Provider components are WASIp2 components but are not WASI command programs. +The Linker supplies neither `wasi:cli`, inherited environment, ambient clocks +or random, filesystem preopens, sockets, raw HTTP, nor a caller-controlled +process API. Every non-foundation import must: + +1. be an exact versioned WIT interface listed by the profile's `capability` + declarations; +2. represent one provider-domain operation with typed inputs, outputs, policy + denials, and operational failures; +3. keep credentials, endpoint selection, allowlists, and authoritative + resource handles in host state rather than guest-selected strings; +4. define request, response, retained-output, concurrency, and deadline bounds; +5. redact secrets and provider payloads from errors, health, and receipts; and +6. be async and cancellation-safe when it can block. + +For example, a GitHub Issue source capability may accept a typed +`{ owner, repository, number }` and return a bounded typed issue response while +the host fixes HTTPS endpoint policy, authentication, redirects, and deadlines. +A local PTY statistics capability may accept a closed `scope` variant while the +host fixes the executable, argument shape, empty environment, working +directory, output caps, deadline, and process containment. An interface that +accepts a URL, executable path, arbitrary argument vector, environment, cwd, +filesystem path, socket address, or shell text is generic authority even if its +package name sounds domain-specific and is non-conforming. + +Cancellation owns both sides of the boundary. Epoch interruption bounds +non-yielding guest CPU; cancellation or drop of the invocation future must +cancel blocking host imports; capability implementations must reap owned work; +and Store drop occurs only after the in-flight call no longer borrows it. A +cancelled observation cannot commit even if the component already returned a +value. + +The executor enforces finite component-byte, memory, table, fuel, result, and +deadline bounds as one versioned Engine policy. Snapshot bytes decode to at +most 1 MiB; canonical selector JSON is at most 16 KiB; health and failed-result +diagnostics are at most 16 KiB UTF-8; and one `Publication` carries at most 32 +ordered facts with the key and value limits in +[`PROFILE-R16A`](./requirements.md). Bounds are checked before allocation or +decoding where the typed transport permits. Values are rejected, never +truncated. + +Observation uses one directly linked provider component. There is no +long-lived runtime topology, stdin/stdout protocol, runtime-selected WAC graph, +or parallel native-provider lifecycle. Provider ETags, cursors, conditional +caches, rate limits, webhook repair, and backoff therefore live in bounded +host-owned domain capability state or explicit durable provider state, never +in Store lifetime. + +## Demand invocation (PROFILE-R16B..R16D) + +Demand remains deny-by-default. A descriptor must declare `demand` before the +host supplies a positive demand watermark in `ObserveRequest`. A missing +watermark is an ordinary scheduled observation; a present watermark identifies +host work but is not provider history, a provider-specific reconcile command, +or authority to mutate provider state. + +For each active binding generation the host keeps at most one observation in +flight and one latest trailing demand watermark. Demand admitted during the +in-flight invocation survives its result and coalesces into the trailing +invocation. Replacement of the binding generation fences the old invocation. +Executor failure provides failure evidence. A client wait deadline limits only +that client's wait and never cancels admitted demand. + +The private durable request and receipt records remain bounded to 64 KiB and +use schema identities `st2.resource-observe-request.v1` and +`st2.resource-observe-receipt.v1`. One supervisor scope admits at most 256 +unresolved requests and scans at most that cap. An admitted request remains the +durable retryable intent until a terminal receipt commits; in-memory enqueue, +client disconnect, wait expiry, and nonterminal receipts do not transfer or +cancel ownership. + +Receipt statuses are camelCase. `accepted` and `backpressured` are nonterminal. +The terminal set is exactly `settledUnchanged`, `settledChanged`, +`settledFailed`, `absentBinding`, `staleGeneration`, and +`providerUnavailable`. Only `settledChanged` carries the host-computed digest. +An active provider without `demand` maps to `absentBinding` with diagnostic +`the profile component does not declare the demand capability`. A client +generation older than the resident generation is `staleGeneration`; a newer +generation remains queued until supervisor refresh. + +## Fenced atomic publication (PROFILE-R14, R16) + +Before invocation the host reads the binding's current state and creates: + +```rust +ProposalFence { + generation, + revision, + prior_digest, } ``` -`runtime` is forbidden unless the descriptor declares `observe`, and -`observe` is unusable without `runtime`. The block accepts exactly one -non-empty `argv` child and an optional unique `capability "demand"` child; it -never invokes a shell. Demand is denied by default, so a runtime that has not -declared the capability receives neither `Observe` nor an expectation to emit -`ObservationResult`. The executable is an external operator-trusted input; the -guest cannot choose or rewrite it. Environment, credentials, egress, and -provider permissions belong to the downstream runtime deployment and are not -inferred from URI possession. - -The descriptor selects `shared` or `perBinding` topology. `shared` starts one -runtime for the exact `(catalog, scheme, profile generation)` and multiplexes -bindings. `perBinding` starts one instance for each active binding. A -per-binding runtime is the same protocol with one registration; topology does -not select another lifecycle model. - -Both modes speak the same ABI-3, newline-delimited JSON protocol over -supervisor-owned stdin/stdout. The following notation shows the normalized -messages. Message types and fields lower to camel case; the nested result is -tagged by `status`: +`generation` changes when the binding is replaced. `revision` advances only on +a committed publication in that generation. `prior_digest` is the current +carrier digest or absence. The fence belongs to the host invocation; the +component cannot choose it or refresh it after observation. + +`Unchanged` closes the observation without mutation. `Failed` records bounded +health and preserves the last proven carrier. For `Published`, the host pairs +the returned `Publication` with the invocation fence, validates current binding +identity, schema and media type, byte and fact bounds, published topics, +selector relevance, and path containment, then computes the authoritative +snapshot digest and deterministic proposal identity. + +The validated internal `PublicationIntent` carries binding identity, the +deterministic `proposal_id`, generation, expected revision and prior digest, +resulting digest, selected topics, and ordered facts. It contains delivery +intent and semantic metadata, not snapshot bytes or provider credentials. + +The proposal ID is: ```text -Publication { - schemaId, mediaType, bytes, topics, facts? -} +SHA-256( + "st2.resource-publication-proposal.v1\0" || + serde_json::to_vec(ProposalIdentity { + bindingId, + generation, + expectedRevision, + priorDigest, + digest, + selectedTopics, + facts + }) +) +``` -host -> Register { - owner: { incarnation, claim }, - bindingId, registration, uri, selector, carrierPath, previousDigest? -} -host -> Unregister { - owner: { incarnation, claim }, - bindingId, registration -} -host -> Observe { - owner: { incarnation, claim }, - bindingId, registration, demandWatermark -} +`ProposalIdentity` is serialized as camelCase JSON with fields in exactly the +displayed order. `digest` is the host-computed accepted snapshot digest; +`selectedTopics` and ordered `facts` are the post-validation semantic envelope. +Including that envelope prevents equal carrier bytes with conflicting delivery +meaning from sharing an identity. -runtime -> Publish { - owner: { incarnation, claim }, - bindingId, registration, - ...Publication -} -runtime -> Health { - owner: { incarnation, claim }, - bindingId?, registration?, - state: starting|ready|degraded|failed, detail? +```rust +ProposalCommit::Committed(PublicationCommit) +ProposalCommit::AlreadyCommitted(PublicationCommit) +ProposalCommit::Unchanged { generation, revision, digest } +ProposalCommit::StaleGeneration { actual_generation, actual_revision } +ProposalCommit::StalePrior { + actual_generation, + actual_revision, + actual_digest, } -runtime -> ObservationResult { - owner: { incarnation, claim }, - bindingId, registration, demandWatermark, - result: - { status: unchanged } - | { status: failed, diagnostic? } - | { status: published, publication: Publication } + +PublicationCommit { + proposal_id, + generation, + resulting_revision, + digest, } ``` -`Publication` is one reusable typed payload, not two similar publication -shapes. Periodic `Publish` flattens it into the existing ABI-3 base wire shape; -the published demand result carries the same value atomically with the outcome. -Neither message contains a host timestamp or runtime-computed digest. - -The supervisor assigns a fresh directional owner claim to every runtime -incarnation. A new claim fences the prior process and clears its binding -registrations. Every `Publish`, `ObservationResult`, binding-scoped `Health`, -and `Observe` dispatch is accepted or addressed only when owner claim, -`bindingId`, and host-generated registration token all match current state. -`bindingId` is an opaque incarnation-scoped address, never the binding name or -URI. - -EOF ends the runtime protocol. The supervisor's process lifecycle is the only -shutdown and restart authority; there is no protocol `Shutdown` message. The -runtime begins or resumes provider-native observation after `Register` and may -use `previousDigest` to avoid redundant periodic publication. `Observe` is a -level-triggered scheduling hint: it may pull an eligible observation forward, -but it is not provider reconciliation and cannot choose a provider mechanism, -reset polling cadence, backoff, cache, cursor, or rate-limit state, or authorize -a provider write. - -Each encoded protocol line is at most 2 MiB, including the newline. Snapshot -`bytes` use padded RFC 4648 base64 and decode to at most 1 MiB of opaque bytes. -Selectors are at most 16 KiB as canonical compact JSON. Health `detail` and a -failed-result `diagnostic` are each at most 16 KiB of UTF-8. A `Publication` -has at most 32 ordered facts; keys are at most 128 bytes and before/after -values are at most 1 KiB of printable single-line UTF-8. A fact carries `key` -plus `before`, `after`, or both; explicit JSON null denotes absence. Bounds are -checked before allocation or decoding where the transport permits and fail -only the affected binding or runtime. st2 does not truncate snapshot bytes, -facts, health text, or diagnostics to satisfy a bound. - -The host rejects unknown bindings, stale owners or registrations, zero demand -watermarks, mismatched schema or media type, unpublished topics, invalid facts -or messages, output after `Unregister`, and messages exceeding protocol bounds. -A shared-runtime protocol failure degrades every registered binding honestly -but cannot publish or settle demand across schemes, profile generations, -runtime incarnations, or binding registrations. - -For each exact active registration the supervisor has at most one `Observe` -dispatch in flight and one latest trailing demand watermark. Watermarks are -positive and monotonically increase within that registration. A matching -`ObservationResult` closes exactly the in-flight batch. Demand accepted while -that observation is in flight survives its result and coalesces into one -trailing dispatch. A registration replacement fences the old batch, and -provider-process or transport failure supplies failure evidence for it. -Backpressure leaves admitted, undispatched demand pending. No timeout, wall -clock, or normal polling cycle completes demand. - -The private durable request and receipt records are bounded to 64 KiB and carry -exact schema identities `st2.resource-observe-request.v1` and -`st2.resource-observe-receipt.v1`. One supervisor scope admits at most 256 -unresolved requests. Submission beyond that cap returns backpressure before -creating another request, and the supervisor scans no more than the cap. An -admitted request remains the durable retryable intent until a terminal receipt -is durably committed; in-memory enqueue and a nonterminal receipt are not -ownership transfer. -Terminal receipt failure keeps retryable state and leaves the request -available to a restarted supervisor. A terminal receipt is the durable -successor and only then permits request cleanup. - -Durable JSON receipt status values are camelCase. `accepted` and `backpressured` -are nonterminal. The terminal set is exactly `settledUnchanged`, -`settledChanged`, `settledFailed`, `absentBinding`, `staleGeneration`, and -`providerUnavailable`. Human CLI text renders multiword statuses in kebab-case. -`Unchanged` maps to `settledUnchanged`; `Failed` maps to `settledFailed` after -its provider diagnostic is normalized to a receipt-safe optional bounded value; -and an accepted `Published` maps to `settledChanged` with the host-computed -digest of its accepted bytes. No other receipt status carries a digest. - -A missing active binding reports `absentBinding`. An active observable binding -whose runtime did not declare `demand` also reports `absentBinding`, with the -explicit diagnostic `the profile runtime does not declare the demand -capability`. Only a client generation older than the resident supervisor is -`staleGeneration`; a newer client generation remains queued until supervisor -refresh. Provider failure reports `providerUnavailable`. A client wait bound -controls only how long that client waits and performs a final receipt read at -the deadline. Expiry or disconnect leaves admitted demand and any trailing -dispatch obligation intact. - -Any provider cursor, webhook delivery identity, redelivery, polling interval, -rate-limit state, conditional cache, backoff, and repair strategy remain -runtime-private. - -## Snapshot publication (PROFILE-R14) - -The resolver's contained carrier is the observable snapshot authority. -Periodic `Publish` and demand-result `Published` enter one host-owned acceptance -transaction: +`Committed` is the single state transition. `AlreadyCommitted` is an +idempotent retry of the same deterministic proposal and returns the original +commit identity. `Unchanged` reports equal accepted bytes without advancing +revision or creating an outbox intent. `StaleGeneration` fences replacement. +`StalePrior` rejects a competing proposal whose expected revision or digest is +no longer current. Concurrent proposals from one prior state therefore have at +most one winner. + +The storage transaction makes these values visible together: ```text -validate current fences + Publication schema + topics + facts + bounds - | - v -compute SHA-256 digest from accepted snapshot bytes - | - v -atomically replace the contained carrier and record current digest + freshness - | - `-> equal digest: no state transition - changed digest: apply selector and retain selected topics + facts +contained carrier bytes +current generation + resulting revision + digest + freshness +lastIntent: complete deterministic PublicationIntent +pending delivery reducer state ``` -The runtime never writes the carrier directly and never supplies its -authoritative digest. Existing descriptor-relative no-follow containment -applies to publication. Failure before acceptance preserves the last proven -snapshot and marks publication health degraded. - -The initial accepted publication changes the binding from unavailable to -readable. If it carries at least one selected topic, st2 schedules the same -superseding invalidation as for a later changed digest. This wake prevents a -live agent from retaining an unreadable view after delayed startup or recovery. -Equal publications and publications without selected topics remain silent. - -`ObservationResult.Unchanged` closes demand without changing the carrier or -freshness. `ObservationResult.Failed` closes demand as failed and preserves the -last proven carrier. `ObservationResult.Published` is not settled until its -embedded `Publication` passes the same acceptance transaction as periodic -`Publish`. Once the snapshot and catch-up transaction commits, it settles as -`settledChanged` with the host-computed accepted digest even if subsequent -resync delivery emission fails. Its bytes, topics, and typed facts cannot -disagree with a separate settlement frame because no such frame exists. - -Snapshot bytes are profile-defined and opaque to st2. `schemaId` and -`mediaType` make the bytes interpretable without making st2 own their semantics. -Each binding has one snapshot, not named facets, a generation manifest, a -profile event log, or a host retention history. +Before that publication point, readers observe the complete prior state and no +new intent. After it, readers observe the complete successor and its intent. +A process crash before publication leaves the prior state authoritative. A +crash after publication but before acknowledgement leaves the successor and +intent durable. Retrying the same proposal returns `AlreadyCommitted`; an +outbox worker may retry delivery by `proposal_id` until its durable +acknowledgement exists. + +Equal snapshot bytes return `Unchanged`; they do not advance revision or create +an intent. The initial accepted publication changes the binding from +unavailable to readable; when it has selected topics, its atomic intent +schedules the same superseding invalidation as a later relevant change. + +Atomic publication is runtime-neutral: passive observation and the component +executor submit the same fenced `Publication` to one host API. The foundation +is conforming without an enabled executor, but observable provider execution is +conforming only through the component world; there is no native fallback. ## Semantic invalidation and catch-up (PROFILE-R17..R20) -For every changed digest, including the initial accepted publication, the -common acceptance core preserves the `Publication`'s ordered facts and -intersects its topics with the normalized binding selector. An empty -intersection updates canonical state and freshness without scheduling delivery. -A non-empty intersection updates this bounded per-binding state: +For every changed digest, including the initial accepted publication, proposal +validation preserves the `Publication`'s ordered facts and intersects its topics +with the normalized binding selector before commit. The resulting +`PublicationIntent` retains selected topics and facts with the committed +snapshot digest. An empty intersection updates canonical state and freshness +but requires no agent wake. + +The durable binding and catch-up state is: ```text +generation: u64 +revision: u64 current_snapshot_digest: Digest? +last_intent: PublicationIntent? last_delivered_digest: Digest? pending_relevant_change: bool +pending_from_last_intent: bool pending_selected_topics: Topic[] pending_facts: ResourceFact[] deliverable: bool ``` -If delivery is available, st2 emits one event on the existing built-in -`resync` stream: +`last_intent` is the full deterministic intent: proposal and binding IDs, +generation, expected revision, prior and resulting digests, selected topics, +and ordered facts. `last_commit()` derives its receipt from that authority +rather than storing a second commit record. When pending delivery refers to +`last_intent`, `pending_from_last_intent` is true and the pending topic/fact +fields stay empty; readers derive that envelope from the intent. + +Publication never calls the event sink inside the commit transaction. Recovery +folds an eligible staged WAL intent into this durable state atomically with the +carrier and removes the WAL; eligibility requires its resulting digest to match +the authoritative carrier. A separate outbox worker reads `last_intent`. If the +resulting state is relevant and delivery is available, the worker emits one +event on the existing built-in `resync` stream: ```text stream = resync key = binding name supersede = true +idempotency = PublicationIntent.proposal_id subject = · [] body = { binding, snapshotDigest, topics, facts } ``` @@ -639,32 +698,58 @@ Subjects are at most 96 Unicode scalars. Facts retain publication order and are included only whole; topic space is reserved before facts are admitted. If no fact fits, a compatible bounded fallback remains. The durable body retains the complete bounded fact list. It contains no snapshot bytes, provider payload, -credential, URI, reason, or provider cursor. Existing event deduplication, -inbox storage, DING rendering, and supersession apply unchanged. Multiple -topics for one atomic publication produce one invalidation. - -If delivery is unavailable, a relevant publication replaces the pending -selected topics and facts with that latest relevant semantic envelope and sets -`pending_relevant_change = true`. A later irrelevant publication may advance -`current_snapshot_digest` but does not clear the pending envelope. When -delivery becomes available, st2 emits at most one invalidation for the -then-current digest with the retained latest relevant topics and facts, and -clears pending state only after event ingress accepts the record. No transition -backlog exists. This is level-triggered current-state catch-up, not event replay. - -Health has separate descriptor, selector, runtime, observation, publication, -and delivery stages. Every stage reports affected scheme and binding without -including URI credentials or provider payloads. The last proven snapshot stays -readable with explicit freshness when observation fails; failure never relabels -old bytes as newly observed state. +credential, URI, reason, or provider cursor. Existing inbox storage, DING +rendering, deduplication, and supersession apply unchanged. Multiple topics for +one atomic publication produce one invalidation. + +Acknowledgement is a separate durable transition keyed by deterministic +`proposal_id`. Loss of acknowledgement leaves the intent retryable; a retry +uses the same event identity. An obsolete intent is not replayed as history: +the worker reconciles against current binding state and may replace delivery +with at most one invalidation for the then-current digest. + +If delivery is unavailable, a relevant publication sets +`pending_relevant_change` and initially points it at `last_intent` without +duplicating topics or facts. Before a later irrelevant publication replaces +`last_intent`, the reducer materializes the older relevant envelope into +`pending_selected_topics` and `pending_facts` and clears +`pending_from_last_intent`. The later publication may advance the current +digest but does not clear pending relevance. When delivery becomes available, +catch-up emits at most one invalidation for the then-current digest with the +retained latest relevant envelope, and clears pending state only after durable +acknowledgement. No transition backlog is delivered. This is level-triggered +current-state catch-up, not event replay. + +Health has separate component loading, descriptor, selector, capability, +observation, proposal validation, publication, and delivery stages. Every stage +reports affected scheme and binding without including URI credentials or +provider payloads. The last proven snapshot stays readable with explicit +freshness when observation fails; failure never relabels old bytes as newly +observed state. + +## Deliberate exclusions and evidence gates (PROFILE-R21..R25) + +| Exclusion | Evidence required before reconsideration | +| --- | --- | +| WASIp3 production execution | A stable Wasmtime/WIT toolchain and component ecosystem, a production provider requirement that WASIp2 cannot express, cross-version fixtures, and cancellation plus capability-containment evidence at least as strong as the WASIp2 suite. | +| Store or instance pooling | Representative provider profiles showing fresh Store creation materially violates an accepted latency or resource bound, plus an exhaustive reset proof covering guest memory, tables, resources, host state, traps, fuel, deadlines, async imports, and cancellation with zero cross-observation leakage. | +| Generic exec, raw HTTP, arbitrary filesystem, or raw socket authority | A real provider operation that cannot be represented as a narrower typed domain interface, an explicit threat and credential model, fixed authority and resource bounds, cancellation/containment tests, and an accepted decision for the enlarged trust boundary. | +| Parallel native or host-process provider framework | A production provider that cannot run through the component world, measurements showing the incompatibility is fundamental rather than packaging cost, and evidence that a second lifecycle, health, fencing, and security model is safer and simpler than extending typed capabilities. | +| Runtime WAC or provider-selected component graph | At least two production providers requiring runtime composition, a closed graph ownership and versioning model, transitive capability review, deterministic failure/fencing semantics, and measurements showing host linkage is the limiting constraint. | + +These are evidence gates, not deferred implementation commitments. Until a gate +is met and an accepted decision changes the contract, the excluded mechanism is +non-conforming. ## Design questions -- **DQ-P1 ABI compatibility:** Prove descriptor and host-protocol compatibility - with frozen fixtures and cross-version conformance tests before third-party - implementations. -- **DQ-P2 Runtime observability:** Derive the minimum low-noise health, freshness, - log, span, metric, and operator surfaces from GitHub-profile dogfood. +- **DQ-P1 Component compatibility:** Prove old-component/new-host, + new-component/old-host, provider-world, and domain-capability compatibility + with frozen WIT fixtures and a cross-version conformance matrix before + independently released third-party components or capabilities. +- **DQ-P2 Provider observability:** Derive the minimum low-noise component, + capability, proposal, publication, freshness, outbox, and delivery health + surfaces from one operated GitHub provider. Resolved design questions and their evidence remain recorded in [`open-questions.md`](./open-questions.md). diff --git a/src/resource_profile.rs b/src/resource_profile.rs index 282554ae..2831358b 100644 --- a/src/resource_profile.rs +++ b/src/resource_profile.rs @@ -20,16 +20,20 @@ pub use st2_resource_protocol::{ BindingId, FactError, FactValue, HostMessage, MAX_FACT_KEY_BYTES, MAX_FACT_VALUE_BYTES, MAX_FACTS, MAX_HEALTH_DETAIL_BYTES, MAX_OBSERVATION_DIAGNOSTIC_BYTES, MAX_PROTOCOL_LINE_BYTES, MAX_SELECTOR_BYTES, MAX_SNAPSHOT_BYTES, ObservationResult, OpaqueIdError, OwnerClaim, - ProtocolError, Publication, RegistrationToken, ResourceFact, RuntimeHealthState, - RuntimeIncarnation, RuntimeMessage, RuntimeOwner, SnapshotBytes, SnapshotDigest, + ProposalCommit, ProposalFence, ProposalId, ProtocolError, Publication, PublicationCommit, + RegistrationToken, ResourceFact, RuntimeHealthState, RuntimeIncarnation, RuntimeMessage, + RuntimeOwner, SnapshotBytes, SnapshotDigest, SnapshotSizeError, decode_host_line, decode_runtime_line, encode_host_line, encode_runtime_line, }; -// Covers the prior state envelope plus 32 maximally sized facts after worst-case JSON escaping. -const MAX_CATCH_UP_FILE_BYTES: usize = 256 * 1024; +// Covers distinct latest-transition and retained-delivery envelopes with 32 maximally sized facts +// each after worst-case JSON escaping. +const MAX_CATCH_UP_FILE_BYTES: usize = 512 * 1024; const CATCH_UP_FILE: &str = "resource-profile-catch-up.json"; const PUBLICATION_INTENT_FILE: &str = "resource-profile-publication-intent.json"; +const PUBLICATION_LOCK_FILE: &str = "resource-profile-publication.lock"; +const PROPOSAL_ID_DOMAIN: &[u8] = b"st2.resource-publication-proposal.v1\0"; static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone, PartialEq, Eq)] @@ -773,10 +777,18 @@ pub struct CatchUpState { last_delivered_digest: Option, pending_relevant_change: bool, #[serde(default)] + pending_from_last_intent: bool, + #[serde(default)] pending_selected_topics: Vec, #[serde(default)] pending_facts: Vec, deliverable: bool, + #[serde(default)] + generation: u64, + #[serde(default)] + revision: u64, + #[serde(default)] + last_intent: Option, } impl CatchUpState { @@ -793,34 +805,111 @@ impl CatchUpState { } pub fn pending_selected_topics(&self) -> &[String] { - &self.pending_selected_topics + if self.pending_from_last_intent { + self.last_intent + .as_ref() + .map_or(&[], |intent| intent.selected_topics.as_slice()) + } else { + &self.pending_selected_topics + } } pub fn pending_facts(&self) -> &[ResourceFact] { - &self.pending_facts + if self.pending_from_last_intent { + self.last_intent + .as_ref() + .map_or(&[], |intent| intent.facts.as_slice()) + } else { + &self.pending_facts + } } pub fn deliverable(&self) -> bool { self.deliverable } + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn proposal_fence(&self) -> ProposalFence { + ProposalFence::new( + self.generation, + self.revision, + self.current_snapshot_digest, + ) + } + + pub fn last_commit(&self) -> Option { + self.last_intent + .as_ref() + .and_then(|intent| intent.commit().ok()) + } + fn validate(&self) -> Result<(), CatchUpError> { if self.pending_relevant_change && self.current_snapshot_digest.is_none() { return Err(CatchUpError::InvalidState( "pending relevance requires a current snapshot digest", )); } - if self.pending_relevant_change != !self.pending_selected_topics.is_empty() { + if self.pending_from_last_intent { + if !self.pending_relevant_change { + return Err(CatchUpError::InvalidState( + "pending last intent requires pending relevance", + )); + } + if !self.pending_selected_topics.is_empty() || !self.pending_facts.is_empty() { + return Err(CatchUpError::InvalidState( + "pending last intent must not duplicate its semantic envelope", + )); + } + if self + .last_intent + .as_ref() + .is_none_or(|intent| intent.selected_topics.is_empty()) + { + return Err(CatchUpError::InvalidState( + "pending last intent requires a relevant durable intent", + )); + } + } else if self.pending_relevant_change != !self.pending_selected_topics.is_empty() { return Err(CatchUpError::InvalidState( "pending relevance and selected topics disagree", )); } - validate_persisted_topics(&self.pending_selected_topics)?; - validate_persisted_facts(&self.pending_facts)?; - if !self.pending_relevant_change && !self.pending_facts.is_empty() { + validate_persisted_topics(self.pending_selected_topics())?; + validate_persisted_facts(self.pending_facts())?; + if !self.pending_relevant_change + && (!self.pending_selected_topics.is_empty() || !self.pending_facts.is_empty()) + { return Err(CatchUpError::InvalidState( - "pending facts require a pending relevant change", + "pending semantic envelope requires a pending relevant change", )); } + if let Some(intent) = self.last_intent.as_ref() { + intent.validate()?; + let commit = intent.commit()?; + if commit.generation() > self.generation { + return Err(CatchUpError::InvalidState( + "last intent generation is newer than catch-up state", + )); + } + if commit.revision() > self.revision { + return Err(CatchUpError::InvalidState( + "last intent revision is newer than catch-up state", + )); + } + if commit.revision() == self.revision + && self.current_snapshot_digest != Some(commit.digest()) + { + return Err(CatchUpError::InvalidState( + "last intent digest differs from current snapshot", + )); + } + } Ok(()) } } @@ -849,27 +938,98 @@ impl DeliveryRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct PublicationIntent { + proposal_id: ProposalId, + binding_id: BindingId, + generation: u64, + expected_revision: u64, + #[serde(skip_serializing_if = "Option::is_none")] + prior_digest: Option, + digest: SnapshotDigest, + selected_topics: Vec, + #[serde(default)] + facts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LegacyPublicationIntent { digest: SnapshotDigest, selected_topics: Vec, #[serde(default)] facts: Vec, } +impl LegacyPublicationIntent { + fn validate(&self) -> Result<(), CatchUpError> { + validate_persisted_topics(&self.selected_topics)?; + validate_persisted_facts(&self.facts) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum StoredPublicationIntent { + Current(PublicationIntent), + Legacy(LegacyPublicationIntent), +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProposalIdentity<'a> { + binding_id: &'a BindingId, + generation: u64, + expected_revision: u64, + prior_digest: Option, + digest: SnapshotDigest, + selected_topics: &'a [String], + facts: &'a [ResourceFact], +} + impl PublicationIntent { - fn from_outcome(outcome: &PublicationOutcome) -> Self { - Self { - digest: outcome.digest, - selected_topics: outcome.selected_topics.clone(), - facts: outcome.facts.clone(), - } + + fn commit(&self) -> Result { + let revision = self + .expected_revision + .checked_add(1) + .ok_or(CatchUpError::InvalidState( + "publication revision counter exhausted", + ))?; + Ok(PublicationCommit::new( + self.proposal_id, + self.generation, + revision, + self.digest, + )) } fn validate(&self) -> Result<(), CatchUpError> { + let identity = ProposalIdentity { + binding_id: &self.binding_id, + generation: self.generation, + expected_revision: self.expected_revision, + prior_digest: self.prior_digest, + digest: self.digest, + selected_topics: &self.selected_topics, + facts: &self.facts, + }; + if self.proposal_id != proposal_id(&identity) { + return Err(CatchUpError::InvalidState( + "publication intent proposal id is not deterministic", + )); + } validate_persisted_topics(&self.selected_topics)?; validate_persisted_facts(&self.facts) } } +fn proposal_id(identity: &ProposalIdentity<'_>) -> ProposalId { + let encoded = serde_json::to_vec(identity) + .expect("proposal identity contains only infallibly serializable values"); + let mut bytes = Vec::with_capacity(PROPOSAL_ID_DOMAIN.len() + encoded.len()); + bytes.extend_from_slice(PROPOSAL_ID_DOMAIN); + bytes.extend_from_slice(&encoded); + ProposalId::of(&bytes) +} + fn validate_persisted_topics(topics: &[String]) -> Result<(), CatchUpError> { let mut unique = BTreeSet::new(); for topic in topics { @@ -898,20 +1058,7 @@ impl CatchUp { pub fn open(state_directory: &Path) -> Result { validate_absolute_path(state_directory).map_err(CatchUpError::UnsafeStateDirectory)?; let directory = open_absolute_dir(state_directory).map_err(CatchUpError::Io)?; - let state = match read_regular_optional_at( - &directory, - OsStr::new(CATCH_UP_FILE), - MAX_CATCH_UP_FILE_BYTES, - ) { - Ok(Some(bytes)) => { - serde_json::from_slice::(&bytes).map_err(CatchUpError::Json)? - } - Ok(None) => CatchUpState::default(), - Err(BoundedReadError::TooLarge) => return Err(CatchUpError::StateTooLarge), - Err(BoundedReadError::NotRegular) => return Err(CatchUpError::StateNotRegular), - Err(BoundedReadError::Io(error)) => return Err(CatchUpError::Io(error)), - }; - state.validate()?; + let state = read_catch_up_state(&directory)?; Ok(Self { directory, state }) } @@ -924,82 +1071,321 @@ impl CatchUp { Ok(catch_up) } + /// Open for an explicit binding replacement without first reconciling the previous generation. + /// + /// This is the supervisor hot-replacement entrypoint: it can recover a carrier that diverged + /// out of band even when ordinary [`Self::open_for_snapshot`] correctly fails closed. + pub fn open_for_generation_advance( + state_directory: &Path, + target: &SnapshotTarget, + ) -> Result { + let mut catch_up = Self::open(state_directory)?; + catch_up.advance_generation(target)?; + Ok(catch_up) + } + pub fn state(&self) -> &CatchUpState { &self.state } + /// Capture the compare-and-swap fence for provider work computed outside the host lock. + pub fn proposal_fence(&self) -> ProposalFence { + self.state.proposal_fence() + } + + /// Advance the binding generation, fencing every proposal captured by the replaced binding. + /// + /// Unlike ordinary reconciliation, this explicit recovery transition treats the canonical + /// carrier as authoritative: it adopts its current digest (or absence), clears delivery state + /// whose semantic intent can no longer be proven, and invalidates the prior generation's + /// durable intent. Call it on [`Self::open`] or use [`Self::open_for_generation_advance`] so a + /// divergence cannot prevent the replacement that recovers it. + pub fn advance_generation( + &mut self, + target: &SnapshotTarget, + ) -> Result { + let _lock = lock_publication(&self.directory)?; + self.reload()?; + let observed = target.current_digest().map_err(CatchUpError::Publication)?; + let intent = self.read_publication_intent()?; + let mut next = self.state.clone(); + next.generation = next + .generation + .checked_add(1) + .ok_or(CatchUpError::InvalidState( + "binding generation counter exhausted", + ))?; + next.revision = next + .revision + .checked_add(1) + .ok_or(CatchUpError::InvalidState( + "publication revision counter exhausted", + ))?; + next.current_snapshot_digest = observed; + next.last_intent = None; + next.pending_relevant_change = false; + next.pending_from_last_intent = false; + next.pending_selected_topics.clear(); + next.pending_facts.clear(); + self.commit_state(next)?; + if intent.is_some() { + self.clear_publication_intent()?; + } + Ok(self.state.proposal_fence()) + } + + /// Preserve the PR #404 single-writer publication API. + /// + /// New provider boundaries should capture [`Self::proposal_fence`] before computing and use + /// [`Self::commit_proposal`]. This method captures under the same lock for existing runtimes, + /// which never claimed a provider-side compare-and-swap contract. pub fn publish( &mut self, publication: AcceptedPublication<'_>, ) -> Result<(PublicationOutcome, Option), PublicationTransactionError> { - let target = publication.target; - self.reconcile_snapshot(target) + let _lock = lock_publication(&self.directory) .map_err(PublicationTransactionError::CatchUp)?; + self.reload() + .map_err(PublicationTransactionError::CatchUp)?; + self.reconcile_snapshot_locked(publication.target) + .map_err(PublicationTransactionError::CatchUp)?; + let fence = self.state.proposal_fence(); + let (_, outcome) = self.commit_proposal_locked(fence, publication)?; + let outcome = outcome.expect("a current fence cannot reject its own publication"); + Ok((outcome, self.pending_delivery())) + } + + /// Validate and commit one provider proposal against its captured generation, revision, and + /// prior digest. + /// Delivery remains level-triggered and separate through [`Self::pending_delivery`] and + /// [`Self::acknowledge_delivery`]. + /// + /// The state directory uses one persistent advisory lock shared by every cooperating writer. + /// Publication relies on same-directory rename atomicity and file-plus-parent-directory + /// `fsync`. The durable intent is written first but is eligible only when its digest matches + /// the canonical carrier; therefore a crash before the carrier rename exposes the old state, + /// while a crash after it is replayed on restart. Filesystems that do not honor those POSIX + /// rename, `fsync`, and `flock` semantics are unsupported. + pub fn commit_proposal( + &mut self, + fence: ProposalFence, + publication: AcceptedPublication<'_>, + ) -> Result { + let _lock = lock_publication(&self.directory) + .map_err(PublicationTransactionError::CatchUp)?; + self.reload() + .map_err(PublicationTransactionError::CatchUp)?; + self.reconcile_snapshot_locked(publication.target) + .map_err(PublicationTransactionError::CatchUp)?; + self.commit_proposal_locked(fence, publication) + .map(|(commit, _)| commit) + } + + fn commit_proposal_locked( + &mut self, + fence: ProposalFence, + publication: AcceptedPublication<'_>, + ) -> Result<(ProposalCommit, Option), PublicationTransactionError> { + let binding_id = publication.binding_id; + let digest = SnapshotDigest::of(publication.bytes.as_slice()); + let identity = ProposalIdentity { + binding_id, + generation: fence.generation(), + expected_revision: fence.revision(), + prior_digest: fence.prior_digest(), + digest, + selected_topics: &publication.selected_topics, + facts: publication.facts, + }; + let proposal_id = proposal_id(&identity); + + if let Some(previous) = self.state.last_intent.as_ref() + && previous.proposal_id == proposal_id + { + let commit = previous.commit().map_err(PublicationTransactionError::CatchUp)?; + return Ok((ProposalCommit::AlreadyCommitted(commit), None)); + } + if fence.generation() != self.state.generation { + return Ok(( + ProposalCommit::StaleGeneration { + actual_generation: self.state.generation, + actual_revision: self.state.revision, + }, + None, + )); + } + if fence.revision() != self.state.revision + || fence.prior_digest() != self.state.current_snapshot_digest + { + return Ok(( + ProposalCommit::StalePrior { + actual_generation: self.state.generation, + actual_revision: self.state.revision, + actual_digest: self.state.current_snapshot_digest, + }, + None, + )); + } + let prepared = publication .prepare() .map_err(PublicationTransactionError::Publication)?; let outcome = prepared.outcome.clone(); - if outcome.change != SnapshotChange::Equal { - self.write_publication_intent(&PublicationIntent::from_outcome(&outcome)) - .map_err(PublicationTransactionError::CatchUp)?; + debug_assert_eq!(digest, outcome.digest); + + if outcome.change == SnapshotChange::Equal { + return Ok(( + ProposalCommit::Unchanged { + generation: self.state.generation, + revision: self.state.revision, + digest: outcome.digest, + }, + Some(outcome), + )); } + let intent = PublicationIntent { + proposal_id, + binding_id: binding_id.clone(), + generation: fence.generation(), + expected_revision: fence.revision(), + prior_digest: fence.prior_digest(), + digest, + selected_topics: outcome.selected_topics.clone(), + facts: outcome.facts.clone(), + }; + intent + .commit() + .map_err(PublicationTransactionError::CatchUp)?; + + self.write_publication_intent(&intent) + .map_err(PublicationTransactionError::CatchUp)?; + publication_checkpoint("after-intent-before-carrier"); prepared .commit() .map_err(PublicationTransactionError::Publication)?; - let delivery = self - .record_publication(&outcome) + publication_checkpoint("after-carrier-before-state"); + let commit = self + .record_intent(&intent) .map_err(PublicationTransactionError::CatchUp)?; - if outcome.change != SnapshotChange::Equal { - self.clear_publication_intent() - .map_err(PublicationTransactionError::CatchUp)?; - } - Ok((outcome, delivery)) + self.clear_publication_intent() + .map_err(PublicationTransactionError::CatchUp)?; + publication_checkpoint("after-state-before-ack"); + Ok((ProposalCommit::Committed(commit), Some(outcome))) } pub fn reconcile_snapshot( &mut self, target: &SnapshotTarget, ) -> Result, CatchUpError> { + let _lock = lock_publication(&self.directory)?; + self.reload()?; + self.reconcile_snapshot_locked(target)?; + Ok(self.pending_delivery()) + } + + fn reconcile_snapshot_locked(&mut self, target: &SnapshotTarget) -> Result<(), CatchUpError> { let observed = target.current_digest().map_err(CatchUpError::Publication)?; let intent = self.read_publication_intent()?; - let mut next = self.state.clone(); match intent.as_ref() { - Some(intent) if observed == Some(intent.digest) => { - next.current_snapshot_digest = observed; - if !intent.selected_topics.is_empty() { - next.pending_relevant_change = true; - next.pending_selected_topics = intent.selected_topics.clone(); - next.pending_facts = intent.facts.clone(); + Some(StoredPublicationIntent::Current(intent)) => { + if observed == Some(intent.digest) { + if self + .state + .last_intent + .as_ref() + .is_none_or(|committed| committed.proposal_id != intent.proposal_id) + { + if self.state.generation != intent.generation + || self.state.revision != intent.expected_revision + || self.state.current_snapshot_digest != intent.prior_digest + { + return Err(CatchUpError::InvalidState( + "published intent does not follow the authoritative fence", + )); + } + self.record_intent(intent)?; + } + } else { + if self.state.revision > 0 + && observed != self.state.current_snapshot_digest + { + return Err(CatchUpError::InvalidState( + "canonical snapshot differs from the authoritative committed digest", + )); + } + if observed.is_none() && self.state.pending_relevant_change { + return Err(CatchUpError::InvalidState( + "a pending invalidation has no readable canonical snapshot", + )); + } + if observed != self.state.current_snapshot_digest { + let mut next = self.state.clone(); + next.current_snapshot_digest = observed; + self.commit_state(next)?; + } } + self.clear_publication_intent()?; } - Some(_) | None => { - if observed.is_none() && next.pending_relevant_change { + Some(StoredPublicationIntent::Legacy(intent)) => { + if self.state.revision > 0 { return Err(CatchUpError::InvalidState( - "a pending invalidation has no readable canonical snapshot", + "legacy publication intent conflicts with current authoritative state", )); } - next.current_snapshot_digest = observed; + let mut next = self.state.clone(); + if observed == Some(intent.digest) { + next.current_snapshot_digest = observed; + if !intent.selected_topics.is_empty() { + next.pending_relevant_change = true; + next.pending_from_last_intent = false; + next.pending_selected_topics = intent.selected_topics.clone(); + next.pending_facts = intent.facts.clone(); + } + } else { + if observed.is_none() && next.pending_relevant_change { + return Err(CatchUpError::InvalidState( + "a pending invalidation has no readable canonical snapshot", + )); + } + next.current_snapshot_digest = observed; + } + if next != self.state { + self.commit_state(next)?; + } + self.clear_publication_intent()?; + } + None => { + if observed != self.state.current_snapshot_digest { + if self.state.revision > 0 { + return Err(CatchUpError::InvalidState( + "canonical snapshot differs from the authoritative committed digest", + )); + } + if observed.is_none() && self.state.pending_relevant_change { + return Err(CatchUpError::InvalidState( + "a pending invalidation has no readable canonical snapshot", + )); + } + let mut next = self.state.clone(); + next.current_snapshot_digest = observed; + self.commit_state(next)?; + } } } - - if next != self.state { - self.commit(next)?; - } - if intent.is_some() { - self.clear_publication_intent()?; - } - Ok(self.pending_delivery()) + Ok(()) } pub fn set_deliverable( &mut self, deliverable: bool, ) -> Result, CatchUpError> { + let _lock = lock_publication(&self.directory)?; + self.reload()?; if self.state.deliverable != deliverable { let mut next = self.state.clone(); next.deliverable = deliverable; - self.commit(next)?; + self.commit_state(next)?; } Ok(self.pending_delivery()) } @@ -1010,12 +1396,14 @@ impl CatchUp { } Some(DeliveryRequest { digest: self.state.current_snapshot_digest?, - selected_topics: self.state.pending_selected_topics.clone(), - facts: self.state.pending_facts.clone(), + selected_topics: self.state.pending_selected_topics().to_vec(), + facts: self.state.pending_facts().to_vec(), }) } pub fn acknowledge_delivery(&mut self, digest: SnapshotDigest) -> Result { + let _lock = lock_publication(&self.directory)?; + self.reload()?; if !self.state.pending_relevant_change || self.state.current_snapshot_digest != Some(digest) { return Ok(false); @@ -1023,12 +1411,43 @@ impl CatchUp { let mut next = self.state.clone(); next.last_delivered_digest = Some(digest); next.pending_relevant_change = false; + next.pending_from_last_intent = false; next.pending_selected_topics.clear(); next.pending_facts.clear(); - self.commit(next)?; + self.commit_state(next)?; Ok(true) } + fn record_intent( + &mut self, + intent: &PublicationIntent, + ) -> Result { + let commit = intent.commit()?; + let mut next = self.state.clone(); + next.current_snapshot_digest = Some(intent.digest); + next.revision = commit.revision(); + if intent.selected_topics.is_empty() && next.pending_from_last_intent { + let previous = next.last_intent.as_ref().ok_or(CatchUpError::InvalidState( + "pending last intent is absent", + ))?; + let selected_topics = previous.selected_topics.clone(); + let facts = previous.facts.clone(); + next.pending_selected_topics = selected_topics; + next.pending_facts = facts; + next.pending_from_last_intent = false; + } + next.last_intent = Some(intent.clone()); + if !intent.selected_topics.is_empty() { + next.pending_relevant_change = true; + next.pending_from_last_intent = true; + next.pending_selected_topics.clear(); + next.pending_facts.clear(); + } + self.commit_state(next)?; + Ok(commit) + } + + #[cfg(test)] fn record_publication( &mut self, outcome: &PublicationOutcome, @@ -1040,22 +1459,28 @@ impl CatchUp { next.pending_selected_topics = outcome.selected_topics.clone(); next.pending_facts = outcome.facts.clone(); } - self.commit(next)?; + self.commit_state(next)?; Ok(self.pending_delivery()) } - fn read_publication_intent(&self) -> Result, CatchUpError> { + fn read_publication_intent(&self) -> Result, CatchUpError> { match read_regular_optional_at( &self.directory, OsStr::new(PUBLICATION_INTENT_FILE), MAX_CATCH_UP_FILE_BYTES, ) { - Ok(Some(bytes)) => { - let intent = serde_json::from_slice::(&bytes) - .map_err(CatchUpError::Json)?; - intent.validate()?; - Ok(Some(intent)) - } + Ok(Some(bytes)) => match serde_json::from_slice::(&bytes) { + Ok(intent) => { + intent.validate()?; + Ok(Some(StoredPublicationIntent::Current(intent))) + } + Err(current_error) => { + let legacy = serde_json::from_slice::(&bytes) + .map_err(|_| CatchUpError::Json(current_error))?; + legacy.validate()?; + Ok(Some(StoredPublicationIntent::Legacy(legacy))) + } + }, Ok(None) => Ok(None), Err(BoundedReadError::TooLarge) => Err(CatchUpError::IntentTooLarge), Err(BoundedReadError::NotRegular) => Err(CatchUpError::IntentNotRegular), @@ -1079,7 +1504,12 @@ impl CatchUp { .map_err(CatchUpError::Io) } - fn commit(&mut self, state: CatchUpState) -> Result<(), CatchUpError> { + fn reload(&mut self) -> Result<(), CatchUpError> { + self.state = read_catch_up_state(&self.directory)?; + Ok(()) + } + + fn commit_state(&mut self, state: CatchUpState) -> Result<(), CatchUpError> { state.validate()?; let mut bytes = serde_json::to_vec(&state).map_err(CatchUpError::Json)?; bytes.push(b'\n'); @@ -1093,6 +1523,66 @@ impl CatchUp { } } +fn read_catch_up_state(directory: &File) -> Result { + let state = match read_regular_optional_at( + directory, + OsStr::new(CATCH_UP_FILE), + MAX_CATCH_UP_FILE_BYTES, + ) { + Ok(Some(bytes)) => { + serde_json::from_slice::(&bytes).map_err(CatchUpError::Json)? + } + Ok(None) => CatchUpState::default(), + Err(BoundedReadError::TooLarge) => return Err(CatchUpError::StateTooLarge), + Err(BoundedReadError::NotRegular) => return Err(CatchUpError::StateNotRegular), + Err(BoundedReadError::Io(error)) => return Err(CatchUpError::Io(error)), + }; + state.validate()?; + Ok(state) +} + +fn lock_publication(directory: &File) -> Result { + let leaf = CString::new(PUBLICATION_LOCK_FILE).expect("lock filename contains no NUL"); + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + leaf.as_ptr(), + libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o600, + ) + }; + if descriptor < 0 { + return Err(CatchUpError::Io(io::Error::last_os_error())); + } + let lock = unsafe { File::from_raw_fd(descriptor) }; + if !lock.metadata().map_err(CatchUpError::Io)?.is_file() { + return Err(CatchUpError::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "publication lock is not a regular file", + ))); + } + if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } < 0 { + return Err(CatchUpError::Io(io::Error::last_os_error())); + } + Ok(lock) +} + +fn publication_checkpoint(stage: &str) { + #[cfg(not(test))] + let _ = stage; + #[cfg(test)] + if std::env::var_os("ST2_RESOURCE_PUBLICATION_CRASH_STAGE").as_deref() + == Some(OsStr::new(stage)) + { + std::process::exit(match stage { + "after-intent-before-carrier" => 71, + "after-carrier-before-state" => 72, + "after-state-before-ack" => 73, + _ => 74, + }); + } +} + #[derive(Debug)] pub enum PublicationTransactionError { Publication(PublicationError), @@ -1372,6 +1862,8 @@ mod tests { use super::*; use std::fs; use std::os::unix::fs::symlink; + use std::io::{BufRead as _, BufReader}; + use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; fn id(value: &str, make: impl FnOnce(String) -> Result) -> T { make(value.to_owned()).unwrap() @@ -1721,41 +2213,417 @@ mod tests { assert_eq!(catch_up.state().pending_facts(), outcome.facts()); } - #[test] - fn durable_intent_recovers_relevance_before_an_equal_republish() { - let directory = tempfile::tempdir().unwrap(); - let state_directory = fs::canonicalize(directory.path()).unwrap(); + fn commit_candidate( + root: &Path, + fence: ProposalFence, + bytes: &[u8], + ) -> ProposalCommit { let current = owner("current"); let mut lifecycle = RuntimeLifecycle::new(); lifecycle.claim(current.clone()); lifecycle - .register(¤t, registration(directory.path(), "token")) + .register(¤t, registration(root, "token")) .unwrap(); - let message = publication(current, "token", b"committed", &["selected"]); - let accepted = accepted_publication(&lifecycle, &message); + let message = publication(current, "token", bytes, &["selected"]); + let state_directory = fs::canonicalize(root).unwrap(); + let snapshot_target = target(root); + let mut catch_up = + CatchUp::open_for_snapshot(&state_directory, &snapshot_target).unwrap(); + catch_up + .commit_proposal(fence, accepted_publication(&lifecycle, &message)) + .unwrap() + } + + fn seed(root: &Path, bytes: &[u8]) -> PublicationCommit { + let state_directory = fs::canonicalize(root).unwrap(); + let catch_up = CatchUp::open_for_snapshot(&state_directory, &target(root)).unwrap(); + match commit_candidate(root, catch_up.proposal_fence(), bytes) { + ProposalCommit::Committed(commit) => commit, + other => panic!("seed publication did not commit: {other:?}"), + } + } - { - let catch_up = CatchUp::open(&state_directory).unwrap(); - let prepared = accepted.prepare().unwrap(); - catch_up - .write_publication_intent(&PublicationIntent::from_outcome(&prepared.outcome)) - .unwrap(); - prepared.commit().unwrap(); + struct PublicationWorker { + child: Child, + input: Option, + output: BufReader, + transcript: String, + } + + impl PublicationWorker { + fn start(root: &Path, bytes: &str, crash_stage: Option<&str>) -> Self { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "resource_profile::tests::atomic_publication_process_worker", + "--ignored", + "--nocapture", + ]) + .env("ST2_RESOURCE_PUBLICATION_WORKER_ROOT", root) + .env("ST2_RESOURCE_PUBLICATION_WORKER_BYTES", bytes) + .env_remove("ST2_RESOURCE_PUBLICATION_CRASH_STAGE") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + if let Some(stage) = crash_stage { + command.env("ST2_RESOURCE_PUBLICATION_CRASH_STAGE", stage); + } + let mut child = command.spawn().unwrap(); + let input = child.stdin.take().unwrap(); + let mut output = BufReader::new(child.stdout.take().unwrap()); + let mut transcript = String::new(); + loop { + let mut line = String::new(); + assert_ne!(output.read_line(&mut line).unwrap(), 0, "{transcript}"); + transcript.push_str(&line); + if line.contains("PUBLICATION-WORKER-READY") { + break; + } + } + Self { + child, + input: Some(input), + output, + transcript, + } } - let snapshot_target = target(directory.path()); - let mut catch_up = CatchUp::open_for_snapshot(&state_directory, &snapshot_target).unwrap(); - assert!(catch_up.state().pending_relevant_change()); - assert_eq!(catch_up.state().pending_selected_topics(), ["selected"]); + fn release(&mut self) { + let mut input = self.input.take().unwrap(); + input.write_all(b"x").unwrap(); + drop(input); + } - let (equal, _) = catch_up - .publish(accepted_publication(&lifecycle, &message)) - .unwrap(); - assert_eq!(equal.change(), SnapshotChange::Equal); - assert!(catch_up.state().pending_relevant_change()); - let request = catch_up.set_deliverable(true).unwrap().unwrap(); - assert_eq!(request.digest(), equal.digest()); - assert_eq!(request.selected_topics(), ["selected"]); + fn finish(mut self) -> (std::process::ExitStatus, String) { + if self.input.is_some() { + self.release(); + } + let status = self.child.wait().unwrap(); + self.output.read_to_string(&mut self.transcript).unwrap(); + (status, self.transcript) + } + } + + #[test] + #[ignore = "subprocess entrypoint for atomic publication tests"] + fn atomic_publication_process_worker() { + let Some(root) = std::env::var_os("ST2_RESOURCE_PUBLICATION_WORKER_ROOT") else { + return; + }; + let root = PathBuf::from(root); + let bytes = std::env::var("ST2_RESOURCE_PUBLICATION_WORKER_BYTES").unwrap(); + let state_directory = fs::canonicalize(&root).unwrap(); + let snapshot_target = target(&root); + let catch_up = CatchUp::open_for_snapshot(&state_directory, &snapshot_target).unwrap(); + let fence = catch_up.proposal_fence(); + println!( + "PUBLICATION-WORKER-READY {} {}", + fence.generation(), + fence.revision() + ); + std::io::stdout().flush().unwrap(); + let mut release = [0_u8; 1]; + std::io::stdin().read_exact(&mut release).unwrap(); + assert_eq!(release, *b"x"); + let result = commit_candidate(&root, fence, bytes.as_bytes()); + println!("PUBLICATION-WORKER-RESULT {result:?}"); + std::io::stdout().flush().unwrap(); + } + + #[test] + fn atomic_publication_fences_races_and_survives_crash_restarts() { + let directory = tempfile::tempdir().unwrap(); + + // POSIX advisory locks are process-scoped, so real child processes (not threads) prove that + // two proposals captured from one prior cannot both pass the host CAS. + let race = directory.path().join("same-prior-race"); + fs::create_dir(&race).unwrap(); + let mut racer_a = PublicationWorker::start(&race, "candidate-a", None); + let mut racer_b = PublicationWorker::start(&race, "candidate-b", None); + racer_a.release(); + racer_b.release(); + let (status_a, output_a) = racer_a.finish(); + let (status_b, output_b) = racer_b.finish(); + assert!(status_a.success(), "{output_a}"); + assert!(status_b.success(), "{output_b}"); + let outputs = format!("{output_a}\n{output_b}"); + assert_eq!( + outputs + .matches("PUBLICATION-WORKER-RESULT Committed(") + .count(), + 1 + ); + assert_eq!( + outputs + .matches("PUBLICATION-WORKER-RESULT StalePrior") + .count(), + 1 + ); + let raced = CatchUp::open_for_snapshot( + &fs::canonicalize(&race).unwrap(), + &target(&race), + ) + .unwrap(); + assert_eq!(raced.state().revision(), 1); + let raced_bytes = fs::read(race.join("snapshot.json")).unwrap(); + assert!( + raced_bytes.as_slice() == b"candidate-a" + || raced_bytes.as_slice() == b"candidate-b" + ); + + // Replacement advances the durable generation while the child is held at an explicit + // pipe barrier; releasing it cannot revive the replaced binding. + let replacement = directory.path().join("stale-generation"); + fs::create_dir(&replacement).unwrap(); + let stale = PublicationWorker::start(&replacement, "stale", None); + let state_directory = fs::canonicalize(&replacement).unwrap(); + let mut host = + CatchUp::open_for_snapshot(&state_directory, &target(&replacement)).unwrap(); + let replacement_fence = host.advance_generation(&target(&replacement)).unwrap(); + assert_eq!(replacement_fence.generation(), 1); + let (status, output) = stale.finish(); + assert!(status.success(), "{output}"); + assert!(output.contains("StaleGeneration"), "{output}"); + assert!(!replacement.join("snapshot.json").exists()); + + // A crash after the durable intent but before carrier rename leaves the old publication + // visible. Restart discards the ineligible intent without manufacturing an outbox item. + let before = directory.path().join("crash-before"); + fs::create_dir(&before).unwrap(); + let seed_commit = seed(&before, b"old"); + let before_revision = seed_commit.revision(); + let worker = PublicationWorker::start( + &before, + "must-not-appear", + Some("after-intent-before-carrier"), + ); + let (status, output) = worker.finish(); + assert_eq!(status.code(), Some(71), "{output}"); + let restarted = + CatchUp::open_for_snapshot(&fs::canonicalize(&before).unwrap(), &target(&before)) + .unwrap(); + assert_eq!(fs::read(before.join("snapshot.json")).unwrap(), b"old"); + assert_eq!(restarted.state().revision(), before_revision); + assert!(!before.join(PUBLICATION_INTENT_FILE).exists()); + + // A crash after carrier rename is caught up from the exact matching intent on restart. + // Current digest, deterministic commit receipt, and delivery envelope then coexist in the + // one authoritative catch-up state; the WAL is no longer a second source of truth. + let catch_up = directory.path().join("restart-catch-up"); + fs::create_dir(&catch_up).unwrap(); + seed(&catch_up, b"old"); + let worker = PublicationWorker::start( + &catch_up, + "recovered", + Some("after-carrier-before-state"), + ); + let (status, output) = worker.finish(); + assert_eq!(status.code(), Some(72), "{output}"); + let recovered = CatchUp::open_for_snapshot( + &fs::canonicalize(&catch_up).unwrap(), + &target(&catch_up), + ) + .unwrap(); + let recovered_digest = SnapshotDigest::of(b"recovered"); + assert_eq!( + recovered.state().current_snapshot_digest(), + Some(recovered_digest) + ); + let receipt = recovered.state().last_commit().unwrap(); + assert_eq!(receipt.digest(), recovered_digest); + let durable_intent = recovered.state().last_intent.as_ref().unwrap(); + assert_eq!(durable_intent.proposal_id, receipt.proposal_id()); + assert_eq!(durable_intent.digest, recovered_digest); + assert_eq!(durable_intent.selected_topics, ["selected"]); + assert!(recovered.state().pending_from_last_intent); + assert!(recovered.state().pending_selected_topics.is_empty()); + assert!(recovered.state().pending_facts.is_empty()); + assert_eq!(recovered.state().pending_selected_topics(), ["selected"]); + assert!(!catch_up.join(PUBLICATION_INTENT_FILE).exists()); + let restarted = CatchUp::open_for_snapshot( + &fs::canonicalize(&catch_up).unwrap(), + &target(&catch_up), + ) + .unwrap(); + assert_eq!(restarted.state(), recovered.state()); + + // The state rename can land even when the acknowledgement is lost. Replaying the exact + // proposal returns its durable receipt and does not advance revision or duplicate intent. + let lost_ack = directory.path().join("lost-ack"); + fs::create_dir(&lost_ack).unwrap(); + seed(&lost_ack, b"old"); + let state_directory = fs::canonicalize(&lost_ack).unwrap(); + let prior = + CatchUp::open_for_snapshot(&state_directory, &target(&lost_ack)).unwrap(); + let retry_fence = prior.proposal_fence(); + let worker = PublicationWorker::start( + &lost_ack, + "committed", + Some("after-state-before-ack"), + ); + let (status, output) = worker.finish(); + assert_eq!(status.code(), Some(73), "{output}"); + let committed = + CatchUp::open_for_snapshot(&state_directory, &target(&lost_ack)).unwrap(); + let committed_revision = committed.state().revision(); + let committed_receipt = committed.state().last_commit().unwrap(); + drop(committed); + assert_eq!( + commit_candidate(&lost_ack, retry_fence, b"committed"), + ProposalCommit::AlreadyCommitted(committed_receipt) + ); + let after_retry = + CatchUp::open_for_snapshot(&state_directory, &target(&lost_ack)).unwrap(); + assert_eq!(after_retry.state().revision(), committed_revision); + assert_eq!(fs::read(lost_ack.join("snapshot.json")).unwrap(), b"committed"); + } + + #[test] + fn generation_advance_explicitly_recovers_diverged_or_missing_carrier() { + let directory = tempfile::tempdir().unwrap(); + let state_directory = fs::canonicalize(directory.path()).unwrap(); + seed(directory.path(), b"committed"); + let before = + CatchUp::open_for_snapshot(&state_directory, &target(directory.path())).unwrap(); + let stale_fence = before.proposal_fence(); + drop(before); + + let worker = PublicationWorker::start( + directory.path(), + "interrupted", + Some("after-intent-before-carrier"), + ); + let (status, output) = worker.finish(); + assert_eq!(status.code(), Some(71), "{output}"); + fs::write(directory.path().join("snapshot.json"), b"operator-recovery").unwrap(); + assert!(matches!( + CatchUp::open_for_snapshot(&state_directory, &target(directory.path())), + Err(CatchUpError::InvalidState( + "canonical snapshot differs from the authoritative committed digest" + )) + )); + assert!(directory.path().join(PUBLICATION_INTENT_FILE).exists()); + + let recovered = CatchUp::open_for_generation_advance( + &state_directory, + &target(directory.path()), + ) + .unwrap(); + assert_eq!(recovered.state().generation(), stale_fence.generation() + 1); + assert_eq!(recovered.state().revision(), stale_fence.revision() + 1); + assert_eq!( + recovered.state().current_snapshot_digest(), + Some(SnapshotDigest::of(b"operator-recovery")) + ); + assert!(recovered.state().last_commit().is_none()); + assert!(!recovered.state().pending_relevant_change()); + assert!(!directory.path().join(PUBLICATION_INTENT_FILE).exists()); + drop(recovered); + assert!(matches!( + commit_candidate(directory.path(), stale_fence, b"late"), + ProposalCommit::StaleGeneration { .. } + )); + assert_eq!( + fs::read(directory.path().join("snapshot.json")).unwrap(), + b"operator-recovery" + ); + fs::write(directory.path().join("snapshot.json"), b"second-divergence").unwrap(); + assert!(matches!( + CatchUp::open_for_snapshot(&state_directory, &target(directory.path())), + Err(CatchUpError::InvalidState( + "canonical snapshot differs from the authoritative committed digest" + )) + )); + + let missing = tempfile::tempdir().unwrap(); + let missing_state = fs::canonicalize(missing.path()).unwrap(); + seed(missing.path(), b"present"); + fs::remove_file(missing.path().join("snapshot.json")).unwrap(); + assert!(matches!( + CatchUp::open_for_snapshot(&missing_state, &target(missing.path())), + Err(CatchUpError::InvalidState( + "canonical snapshot differs from the authoritative committed digest" + )) + )); + let recovered_missing = + CatchUp::open_for_generation_advance(&missing_state, &target(missing.path())).unwrap(); + assert_eq!(recovered_missing.state().current_snapshot_digest(), None); + assert!(recovered_missing.state().last_commit().is_none()); + assert!(!recovered_missing.state().pending_relevant_change()); + } + + #[test] + fn predecessor_publication_intent_migrates_by_digest_and_unknown_shapes_fail_closed() { + let matching = tempfile::tempdir().unwrap(); + let matching_state = fs::canonicalize(matching.path()).unwrap(); + fs::write(matching.path().join("snapshot.json"), b"legacy-carrier").unwrap(); + let legacy = serde_json::json!({ + "digest": SnapshotDigest::of(b"legacy-carrier").to_string(), + "selectedTopics": ["selected"], + "facts": [{"key": "state", "after": "ready"}], + }); + fs::write( + matching.path().join(PUBLICATION_INTENT_FILE), + serde_json::to_vec(&legacy).unwrap(), + ) + .unwrap(); + + let migrated = + CatchUp::open_for_snapshot(&matching_state, &target(matching.path())).unwrap(); + assert_eq!( + migrated.state().current_snapshot_digest(), + Some(SnapshotDigest::of(b"legacy-carrier")) + ); + assert_eq!(migrated.state().pending_selected_topics(), ["selected"]); + assert_eq!( + migrated.state().pending_facts(), + [ResourceFact::current("state", "ready").unwrap()] + ); + assert!(migrated.state().last_commit().is_none()); + assert!(!matching.path().join(PUBLICATION_INTENT_FILE).exists()); + + let mismatched = tempfile::tempdir().unwrap(); + let mismatched_state = fs::canonicalize(mismatched.path()).unwrap(); + fs::write(mismatched.path().join("snapshot.json"), b"old-carrier").unwrap(); + let legacy = serde_json::json!({ + "digest": SnapshotDigest::of(b"never-published").to_string(), + "selectedTopics": ["selected"], + "facts": [], + }); + fs::write( + mismatched.path().join(PUBLICATION_INTENT_FILE), + serde_json::to_vec(&legacy).unwrap(), + ) + .unwrap(); + let migrated = + CatchUp::open_for_snapshot(&mismatched_state, &target(mismatched.path())).unwrap(); + assert_eq!( + migrated.state().current_snapshot_digest(), + Some(SnapshotDigest::of(b"old-carrier")) + ); + assert!(!migrated.state().pending_relevant_change()); + assert!(!mismatched.path().join(PUBLICATION_INTENT_FILE).exists()); + + let malformed = tempfile::tempdir().unwrap(); + let malformed_state = fs::canonicalize(malformed.path()).unwrap(); + fs::write(malformed.path().join("snapshot.json"), b"carrier").unwrap(); + let unknown = serde_json::json!({ + "digest": SnapshotDigest::of(b"carrier").to_string(), + "selectedTopics": [], + "facts": [], + "unknown": true, + }); + fs::write( + malformed.path().join(PUBLICATION_INTENT_FILE), + serde_json::to_vec(&unknown).unwrap(), + ) + .unwrap(); + assert!(matches!( + CatchUp::open_for_snapshot(&malformed_state, &target(malformed.path())), + Err(CatchUpError::Json(_)) + )); + assert!(malformed.path().join(PUBLICATION_INTENT_FILE).exists()); } #[test] diff --git a/tests/invariants.rs b/tests/invariants.rs index 39e8a81d..5f17b116 100644 --- a/tests/invariants.rs +++ b/tests/invariants.rs @@ -2,28 +2,106 @@ use std::collections::BTreeSet; use std::fs; use std::path::Path; +fn raw_string_start(line: &str) -> Option<(usize, usize)> { + let bytes = line.as_bytes(); + for index in 0..bytes.len() { + if index > 0 + && (bytes[index - 1].is_ascii_alphanumeric() + || matches!(bytes[index - 1], b'_' | b'"')) + { + continue; + } + let mut cursor = match bytes[index..] { + [b'r', ..] => index + 1, + [b'b', b'r', ..] => index + 2, + _ => continue, + }; + let mut hashes = 0; + while bytes.get(cursor) == Some(&b'#') { + hashes += 1; + cursor += 1; + } + if bytes.get(cursor) == Some(&b'"') { + return Some((cursor + 1, hashes)); + } + } + None +} + +fn raw_string_closes(line: &str, hashes: usize) -> bool { + line.as_bytes().windows(hashes + 1).any(|window| { + window.first() == Some(&b'"') && window[1..].iter().all(|byte| *byte == b'#') + }) +} + fn declared_tests(source: &str) -> BTreeSet { let mut tests = BTreeSet::new(); + let mut modules = Vec::<(usize, String)>::new(); let mut saw_test_attribute = false; + let mut raw_string_hashes = None; for line in source.lines() { - let trimmed = line.trim(); + if let Some(hashes) = raw_string_hashes { + if raw_string_closes(line, hashes) { + raw_string_hashes = None; + } + continue; + } + if line.trim_start().starts_with("//") { + continue; + } + let code = line; + if let Some((content_start, hashes)) = raw_string_start(code) { + if !raw_string_closes(&code[content_start..], hashes) { + raw_string_hashes = Some(hashes); + } + continue; + } + let trimmed = code.trim(); + let indentation = code.len() - code.trim_start().len(); + if trimmed == "}" { + while modules + .last() + .is_some_and(|(module_indentation, _)| *module_indentation >= indentation) + { + modules.pop(); + } + } + + let declaration = trimmed.strip_prefix("pub ").unwrap_or(trimmed); + if let Some(rest) = declaration.strip_prefix("mod ") + && let Some((name, _)) = rest.split_once('{') + { + modules.push((indentation, name.trim().to_owned())); + saw_test_attribute = false; + continue; + } if trimmed.starts_with("#[") { if trimmed.contains("test") { saw_test_attribute = true; } continue; } - if !saw_test_attribute || trimmed.is_empty() { + if !saw_test_attribute || trimmed.is_empty() || trimmed.starts_with("//") { continue; } - let declaration = trimmed.strip_prefix("pub ").unwrap_or(trimmed); let declaration = declaration.strip_prefix("async ").unwrap_or(declaration); if let Some(rest) = declaration.strip_prefix("fn ") && let Some((name, _)) = rest.split_once('(') { - tests.insert(name.trim().to_owned()); + let name = name.trim(); + tests.insert(name.to_owned()); + if !modules.is_empty() { + let mut qualified = modules + .iter() + .map(|(_, module)| module.as_str()) + .collect::>() + .join("::"); + qualified.push_str("::"); + qualified.push_str(name); + tests.insert(qualified); + } } saw_test_attribute = false; } @@ -31,6 +109,38 @@ fn declared_tests(source: &str) -> BTreeSet { tests } +#[test] +fn declared_tests_preserve_module_qualification() { + let source = r##" +#[test] +fn root_proof() {} + +#[cfg(test)] +mod tests { + fn fixture() -> &'static str { + r#" +} +"# + } + + #[test] + fn module_proof() {} + + mod nested { + #[test] + fn nested_proof() {} + } +} +"##; + let tests = declared_tests(source); + + assert!(tests.contains("root_proof")); + assert!(tests.contains("module_proof")); + assert!(tests.contains("tests::module_proof")); + assert!(tests.contains("tests::nested::nested_proof")); + assert!(!tests.contains("wrong::module_proof")); +} + #[test] fn qualified_proof_references_resolve() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -41,7 +151,7 @@ fn qualified_proof_references_resolve() { if index % 2 == 0 { continue; } - let Some((relative_path, test_name)) = span.rsplit_once("::") else { + let Some((source_path, test_name)) = span.split_once(".rs::") else { if span.ends_with(".rs") { assert!( root.join(span).is_file(), @@ -50,11 +160,9 @@ fn qualified_proof_references_resolve() { } continue; }; - if !relative_path.ends_with(".rs") { - continue; - } + let relative_path = format!("{source_path}.rs"); - let source = fs::read_to_string(root.join(relative_path)) + let source = fs::read_to_string(root.join(&relative_path)) .unwrap_or_else(|error| panic!("proof source {relative_path} is unreadable: {error}")); let tests = declared_tests(&source); assert!(