Skip to content

🐛 Restore the durable eval journal boundary - #394

Merged
taras merged 9 commits into
mainfrom
agent/restore-durable-journal-boundary
Aug 9, 2026
Merged

🐛 Restore the durable eval journal boundary#394
taras merged 9 commits into
mainfrom
agent/restore-durable-journal-boundary

Conversation

@taras

@taras taras commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Why

Merged PR #390 put attached-service collision checks inside a generic durable-operation validation hook. Replay could therefore consume a recorded successful Yield and replace it with an unjournaled validation error. This draft is an architectural correction to #390; the attached-service product contract remains unchanged.

Architecture review also identified terminal paths that could record durability or persistence failures as document outcomes, retained completed children that could escape replay alignment, and caught durability failures that allowed later durable work before termination. This revision closes those boundaries across durable entry, ordered persistence, and root/child termination.

What changes

Before:

  • createDurableOperation() accepted a generic validation callback on live and replay paths.
  • durable eval collision validation ran inside the durable effect.
  • root and child termination could append Close(err) for stale, divergent, or failed-persistence execution.
  • a fail-once backing append could be followed by a compensating terminal event.
  • a retained completed child was treated as aligned even when the current definition never reached it.
  • workflow code could catch a durability failure, consume later replay, start a later executor, and queue another append before the terminal boundary rethrew the first failure.
  • an unmarked backing adapter error whose class looked like replay divergence or stale input escaped without a DurablePersistenceError wrapper.

After:

  • durable eval transforms and validates declared exports against the live overlay before constructing a durable effect.
  • createDurableOperation() again has only its description and executor and preserves persist-before-resume.
  • every unmarked backing append failure raises a new DurablePersistenceError regardless of the adapter error's class, preserves the exact adapter value as cause, and activates shared fail-stop state across the durable coroutine tree.
  • both durable effect factories reject the exact active failure before replay matching or live execution.
  • a shared FIFO append fence rechecks fail-stop state immediately before storage, so an append queued behind the failure never reaches the adapter.
  • root and child termination reject active durability failures before ordinary terminal handling and append no compensating Close.
  • each durable child identity is claimed when the current definition reaches it; every terminal path checks the complete retained subtree for unconsumed yields and unclaimed completed children.
  • guardDurableStream() keeps policy rejection distinct from backing-store failure, preserving the policy error and the existing guarded-journal contract.

How it works

transform eval → validate live collision → construct durable effect → execute → ordered append → resume
                                                   │                    │
active failure ──────────── reject before replay/live                    └─ append failure → fail-stop
                                                                         │
later root/child entry and queued append ─────────────────────────────────┘ reject exact first error

Replay restores only matching recorded results. The first durability failure is shared by the root and children, and durable entry plus the append boundary enforce it even if workflow code catches the error. Before any new root or child Close, durable streams also confirms that the terminating coroutine's retained subtree is aligned and that no durability failure is active.

Review guide

Start with: packages/durable-streams/tests/fail-stop.test.ts and durability.ts

Then review:

  1. packages/durable-streams/effect.ts for pre-replay/pre-executor fail-stop checks and persist-before-resume
  2. packages/durable-streams/run.ts, combinators.ts, and replay-index.ts for terminal alignment and shared child state
  3. packages/durable-streams/tests/terminal-boundary.test.ts and durable-run.test.ts for genuine-history and fail-once regressions
  4. packages/core/src/eval-handler.ts and packages/core/tests/ephemeral-service.test.ts for pre-effect collision validation from the earlier PR revision
  5. architecture.md and the executable/durable-stream specifications for the resulting contract

Look carefully at:

  • a durability failure takes precedence even after replay consumed the final retained Yield
  • a caught first failure fences later replay, executor startup, and storage while preserving exact error and cause identity
  • concurrent work that began earlier cannot be undone, but its not-yet-started append is fenced
  • marked gate rejection is classified by its source and unwrapped as policy; unmarked adapter rejection is classified as persistence failure regardless of class
  • a failed Yield or successful Close append attempts no compensating terminal append
  • child success, failure, and cancellation all enforce the same terminal policy as the root
  • reaching a retained completed child claims its closed descendant subtree, while removing that child is terminal divergence
  • direct guarded-stream callers receive the original policy rejection; the durable runner does not mistake it for backing persistence failure

What must stay true

  • Live durable effects resume only after their Yield append completes — enforced by both effect factories and paused/fail-once stream tests.
  • Durability failures are fail-stop and never document outcomes — enforced by shared first-failure state at durable entry, ordered append, and root/child terminal checks.
  • Ordinary pre-persistence policy rejection remains an ordinary workflow failure — enforced by the guarded-stream boundary and direct workflow/SQLite/secret-detection regressions.
  • Service publication still refuses durable or live names, ephemeral eval still refuses durable names, and existing ephemeral live bindings remain replaceable.
  • API.Service, startService(), service=<binding>, host adapters, the XMD service handshake protocol, the live overlay, and scoped service ownership are unchanged.
  • ✨ Replace free-port probing with attached services #390 provides and tests useWorkflowServiceDenial(); Add the xmd workflow start/resume filesystem vertical slice #366 will install it in future xmd workflow start and resume scopes. No workflow CLI execution branch exists yet.

How to verify it

  • Focused fail-stop regression: 1 suite / 7 steps passed.
  • Unmarked adapter classification table: 5 durability-class adapter errors; each has executors 1/0, adapter attempts 1, stored events 0, a new DurablePersistenceError, and the exact adapter error as cause.
  • Marked policy classification table: 5 durability-class policy errors; each reaches workflow code by exact identity, leaves executors 1/1, and persists exactly Yield(later) plus Close(root) without introducing a wrapper.
  • Transitive affected tests (--related=packages/durable-streams/durability.ts): 293 suites / 2,170 steps passed.
  • Caught persistence: executors 1/0, adapter attempts 1, stored events 0; exact DurablePersistenceError and adapter cause escape.
  • Caught replay divergence: later replay decisions 0, later executors 0, appends 0; retained bytes unchanged.
  • Child/sibling propagation: executors 1/0, adapter attempts 1, stored events 0; both catches and the run observe the same first error.
  • Queued append: already-started executors 1/1, adapter attempts 1, stored events 0; the queued append is fenced before storage.
  • Ordinary guard rejection: executors 1/1, backend events 2 (Yield(later), Close(root)); the run completes normally.
  • deno task lint — passed with 0 errors.
  • deno task check — passed.
  • deno task check:jsrSuccess Dry run complete.
  • deno task verify — all 9 applicable commands passed: vendor 3.1s, lint 2.7s, check 1.2s, Deno 648s, JSR 1.2s, TypeScript 22.3s, Node 235s, Bun 337.8s, docs 16s; tracked tree unchanged.

Scope

Included

  • Restore the pre-✨ Replace free-port probing with attached services #390 durable-operation protocol and keep collision validation in core before effect construction.
  • Prevent durability and backing-persistence failures from becoming root or child terminal events.
  • Fence every later durable entry and ordered append after the first durability failure.
  • Classify marked policy rejection and unmarked backing persistence failure by source rather than error class.
  • Enforce terminal replay alignment across the complete claimed coroutine tree.
  • Replace fabricated replay history with genuine earlier-definition history.
  • Document the journal boundary, built-in modifiers, and current workflow-denial delivery status.

Intentionally unchanged

New abstractions

  • DurablePersistenceError distinguishes backing journal failure from a document failure while retaining the adapter error as its cause.
  • Shared durable failure state preserves the exact first error across root and child entry, append, and termination.
  • DurableAppendFence serializes the final admission to stream storage so queued work can recheck shared fail-stop state at the adapter boundary.
  • Each new abstraction has multiple concrete uses or a clear justification.
  • No speculative functionality is included.

Risks and limitations

  • A guarded stream marks rejected errors with non-enumerable internal metadata so durable execution can distinguish policy rejection from backing failure without changing the public stream API.
  • This intentionally rejects incompatible retained component history; it does not add migration or normalization.
  • Recovery or rollback: revert this corrective PR only together with a deliberate replacement for the durable journal invariant.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

PR #394: 🐛 Restore the durable eval journal boundary

25 files, +1999 / -374

Scope

🔴 PR has 2373 lines changed. Split into focused PRs.

🟡 2373 lines changed. PRs under 400 receive more thorough review.

🟡 25 files changed. Are all changes related?

Structural

Oxlint structural signals:

  • no-unnecessary-type-assertion ×4: packages/durable-streams/effect.ts, packages/core/src/eval-handler.ts
  • no-unused-vars ×1: packages/durable-streams/effect.ts
  • no-empty-function ×1: packages/durable-streams/effect.ts

Slop

✅ Slop indicators look low.

Static Analysis

Oxlint: 204 diagnostics across 10 files (36 rules)
Density: 0.102 violations/added-line

capitalized-comments (58): packages/durable-streams/combinators.ts, packages/core/src/errors.ts, packages/durable-streams/guard.ts (+3)
id-length (16): packages/durable-streams/combinators.ts, packages/core/src/errors.ts, packages/core/src/eval-handler.ts (+2)
max-statements (13): packages/durable-streams/durability.ts, packages/durable-streams/combinators.ts, packages/durable-streams/replay-index.ts (+4)
func-names (11): packages/durable-streams/durability.ts, packages/durable-streams/combinators.ts, packages/core/src/eval-handler.ts (+1)
no-ternary (11): packages/durable-streams/combinators.ts, packages/core/src/errors.ts, packages/core/src/eval-handler.ts (+2)
max-lines-per-function (10): packages/durable-streams/combinators.ts, packages/core/src/eval-handler.ts, packages/durable-streams/run.ts (+1)
no-confusing-void-expression (9): packages/durable-streams/effect.ts
no-unsafe-type-assertion (8): packages/durable-streams/combinators.ts, packages/core/src/eval-handler.ts, packages/durable-streams/run.ts
no-duplicate-imports (7): packages/durable-streams/durability.ts, packages/durable-streams/combinators.ts, packages/core/src/errors.ts (+2)
no-continue (5): packages/durable-streams/durability.ts, packages/durable-streams/replay-index.ts
max-params (5): packages/core/src/errors.ts, packages/core/src/eval-handler.ts, packages/durable-streams/errors.ts (+1)
catch-error-name (5): packages/durable-streams/effect.ts
prefer-readonly (5): packages/durable-streams/replay-index.ts
no-unnecessary-type-assertion (4): packages/durable-streams/effect.ts, packages/core/src/eval-handler.ts
max-lines (3): packages/durable-streams/combinators.ts, packages/core/src/errors.ts, packages/durable-streams/effect.ts
init-declarations (3): packages/durable-streams/combinators.ts, packages/core/src/eval-handler.ts, packages/durable-streams/effect.ts
no-underscore-dangle (3): packages/core/src/eval-handler.ts
no-labels (3): packages/durable-streams/effect.ts
strict-boolean-expressions (3): packages/core/src/eval-handler.ts, packages/core/src/errors.ts
max-classes-per-file (2): packages/core/src/errors.ts, packages/durable-streams/errors.ts
eqeqeq (2): packages/core/src/eval-handler.ts
no-null (2): packages/core/src/eval-handler.ts
no-floating-promises (2): packages/durable-streams/effect.ts
no-base-to-string (2): packages/core/src/eval-handler.ts
method-signature-style (1): packages/durable-streams/context.ts
consistent-existence-index-check (1): packages/durable-streams/durability.ts
max-dependencies (1): packages/core/src/eval-handler.ts
consistent-type-imports (1): packages/core/src/eval-handler.ts
no-immediate-mutation (1): packages/core/src/eval-handler.ts
preserve-caught-error (1): packages/durable-streams/run.ts
no-inline-comments (1): packages/durable-streams/effect.ts
no-unused-vars (1): packages/durable-streams/effect.ts
consistent-function-scoping (1): packages/durable-streams/effect.ts
no-empty-function (1): packages/durable-streams/effect.ts
no-unsafe-argument (1): packages/durable-streams/durability.ts
no-unnecessary-type-conversion (1): packages/core/src/eval-handler.ts

Correctness

No extraneous code patterns detected.

@taras
taras marked this pull request as ready for review August 8, 2026 20:04
@taras
taras marked this pull request as draft August 8, 2026 21:13
@taras
taras marked this pull request as ready for review August 8, 2026 21:32
@taras

taras commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Architecture review: REQUEST CHANGES

The central correction is right: durable eval's live-binding validation belongs in core, after transformation and before createDurableOperation(). Removing the generic validate hook from durable-streams restores the abstraction boundary and prevents replay from replacing a retained Yield with an unjournaled result.

The new terminal boundary is not complete enough to uphold the architecture yet. Three related paths can still convert a durability/persistence failure into a recorded document outcome or accept incompatible retained history.

1. Preserve a durability failure before consulting replay exhaustion

In durableRun(), isDurabilityFailure(primary) is only checked inside if (unconsumed) (run.ts:161-174). That is too late. checkReplay() consumes the matching Yield before returning a replay-guard StaleInputError. If it was the last retained yield, firstUnconsumed() returns nothing and durableRun() appends Close(err) for the stale-input failure. ContinuePastCloseDivergenceError has the same category problem: it can arise with no unconsumed yield.

This contradicts architecture.md §8: a durability failure says the journal no longer describes the document execution, is never the document outcome, and therefore must never be serialized into Close(err).

Make durability-failure routing unconditional and prior to ordinary terminal handling. Add direct durable-stream tests in which:

  • a one-yield partial journal is rejected by a replay guard after that yield is consumed; the exact StaleInputError escapes and the journal remains byte-for-byte unchanged;
  • continue-past-close escapes without any additional terminal event.

2. A failed append is storage failure, not workflow failure

Both live effect factories currently resolve a failed Yield append as the raw error (effect.ts:335-343 and the corresponding callback path). durableRun() then treats it as an ordinary workflow exception. A stream that rejects one append and accepts the next will therefore record a root Close(err) even though the effect ran and its Yield did not persist.

There is an even smaller version at run.ts:152-186: failure of Close(ok) falls into the workflow catch, which may successfully append Close(err). The current injectFailure test keeps failing every append, so the resulting AggregateError hides this fail-once path.

Keep journal I/O failure outside the document outcome. Structure the control flow and/or give failed event persistence a durable-stream-owned failure marker so neither root nor child terminal handling can journal it. Preserve the underlying adapter error as the cause. Add fail-once stream tests for both Yield and Close(ok) append failure; each must attempt no compensating Close, leave no new event, and never resume past an unpersisted effect.

3. Terminal alignment must cover the coroutine tree, including completed children

ReplayIndex.firstUnconsumed() explicitly skips every coroutine with a Close (replay-index.ts:116-145). runDurableChild() fast-paths a retained child Close without marking that child as visited (combinators.ts:57-86). Consequently, this genuine crash prefix is accepted incorrectly:

  1. root.0 completes and has Yield + Close;
  2. the host stops before root Close;
  3. the current definition removes root.0 and the root returns or throws.

The terminal check cannot distinguish that orphaned completed child from a completed child actually recreated by the current scope tree, so it appends a root Close over incompatible history. The “future enhancement” comment records a known violation of the invariant this PR and its specifications claim to establish.

The same terminal policy also has to apply inside runDurableChild(): it currently prepares Close(err) for every thrown error and appends it from ensure, including stale/divergence/persistence failures. That new child Close then causes the root scan to skip the incompatible coroutine.

Track which retained coroutine identities the current run actually claims/visits, and apply one terminal-alignment/durability-failure policy before any root or child Close. Add genuine-history tests for:

  • a completed child/no root-close crash prefix replayed by a definition that removes the child, for both root return and root error;
  • stale/divergent execution inside a child, proving neither child nor root Close is appended and the retained prefix remains unchanged;
  • the compatible definition still replaying the same prefix successfully.

Update the protocol and integration text with the resulting all-coroutine contract. It currently says all terminal paths are resolved while the implementation and ReplayIndex comment explicitly exclude one of them.

Once these are addressed, the PR's eval validation boundary and immediate-root divergence behavior fit the architecture coherently.

@taras
taras marked this pull request as draft August 8, 2026 22:42
@taras

taras commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Addressed the architecture review in fdabbf7f8178e2539c175b2b974d91c13e9b23e0:\n\n- durability failures are now checked unconditionally before root or child terminal handling, including stale-after-last-Yield and continue-past-close;\n- backing append failures raise DurablePersistenceError, retain the adapter error as cause, and cannot produce compensating Close events or resume an unpersisted effect;\n- terminal alignment now tracks claimed coroutine identities and checks the complete retained subtree for every root/child close, including completed orphan children;\n- guarded-stream policy rejection remains distinct from backing persistence failure and preserves the original policy error.\n\nFocused regressions pass (13 suites / 118 steps), the full Deno suite passes (400 suites / 2,747 steps), deno task verify passes all eight lanes with the tracked tree unchanged, and GitHub CI is green (composability skipped by design). PR remains draft for architecture re-review.

@taras
taras force-pushed the agent/restore-durable-journal-boundary branch from 8bbdf5b to 9165cf9 Compare August 9, 2026 00:59
@taras
taras marked this pull request as ready for review August 9, 2026 02:02
@taras
taras merged commit 2a0e0f2 into main Aug 9, 2026
11 checks passed
@taras
taras deleted the agent/restore-durable-journal-boundary branch August 9, 2026 02:03
taras added a commit that referenced this pull request Aug 9, 2026
…nup fatal

Structural parsing runs from `fatalCause`, which every generic catch in
expansion consults. A provider is free to hand back a Proxy that refuses to be
inspected, and one of these parsers throwing would replace the failure being
classified with a failure about classifying it. Every read is now total, and so
is the cause traversal: a hostile wrapper narrows what discovery finds instead.

Recognizing a Files fatal is a decision to let that exact object travel onward
by identity, so it now requires the whole public contract — frozen data with no
extra fields, the fixed diagnostic for its kind, and no cause. A candidate
carrying a raw message or an errno chain is replaced by a fresh invariant
rather than preserved. Durability recognition is unchanged: it stays #394's
class-based mechanism, and only the Files boundary is crossed by a second
loaded copy.

A host cleanup that fails while cancellation is unwinding has no outcome to
report beside it. It leaves the scope as a fixed teardown invariant instead of
manufacturing a write result, carrying neither the platform's error nor the
generated temporary's name.

The loaded-copy claim is now proved by a real second copy: the Files module is
bundled, imported as its own module, and its failures are recognized in both
directions.
taras added a commit that referenced this pull request Aug 9, 2026
…nup fatal

Structural parsing runs from `fatalCause`, which every generic catch in
expansion consults. A provider is free to hand back a Proxy that refuses to be
inspected, and one of these parsers throwing would replace the failure being
classified with a failure about classifying it. Every read is now total, and so
is the cause traversal: a hostile wrapper narrows what discovery finds instead.

Recognizing a Files fatal is a decision to let that exact object travel onward
by identity, so it now requires the whole public contract — frozen data with no
extra fields, the fixed diagnostic for its kind, and no cause. A candidate
carrying a raw message or an errno chain is replaced by a fresh invariant
rather than preserved. Durability recognition is unchanged: it stays #394's
class-based mechanism, and only the Files boundary is crossed by a second
loaded copy.

A host cleanup that fails while cancellation is unwinding has no outcome to
report beside it. It leaves the scope as a fixed teardown invariant instead of
manufacturing a write result, carrying neither the platform's error nor the
generated temporary's name.

The loaded-copy claim is now proved by a real second copy: the Files module is
bundled, imported as its own module, and its failures are recognized in both
directions.
taras added a commit that referenced this pull request Aug 9, 2026
…nup fatal

Structural parsing runs from `fatalCause`, which every generic catch in
expansion consults. A provider is free to hand back a Proxy that refuses to be
inspected, and one of these parsers throwing would replace the failure being
classified with a failure about classifying it. Every read is now total, and so
is the cause traversal: a hostile wrapper narrows what discovery finds instead.

Recognizing a Files fatal is a decision to let that exact object travel onward
by identity, so it now requires the whole public contract — frozen data with no
extra fields, the fixed diagnostic for its kind, and no cause. A candidate
carrying a raw message or an errno chain is replaced by a fresh invariant
rather than preserved. Durability recognition is unchanged: it stays #394's
class-based mechanism, and only the Files boundary is crossed by a second
loaded copy.

A host cleanup that fails while cancellation is unwinding has no outcome to
report beside it. It leaves the scope as a fixed teardown invariant instead of
manufacturing a write result, carrying neither the platform's error nor the
generated temporary's name.

The loaded-copy claim is now proved by a real second copy: the Files module is
bundled, imported as its own module, and its failures are recognized in both
directions.
taras added a commit that referenced this pull request Aug 9, 2026
* 💥 Contain document filesystem access behind API.Files (#227)

`<File>`, `<Glob>` and `<TempDir>` reached the host filesystem directly, so
`xmd run` and a workflow run could not mean the same thing for one document.

Document filesystem access now goes through `API.Files`, a contextual Api of
whole semantic operations with no host default. The four CLI entrypoints
install the host provider explicitly; a run with none installed fails rather
than reaching the host.

The three components make no filesystem call of their own and import no host
path or fs module. What they keep is order: a write's lexical check runs before
its children, and the semantic write that follows repeats admission and owns
every later phase, so the earlier check authorizes nothing.

Ordinary failures cross the boundary as frozen structural data — a reason from
a fixed vocabulary and the phase it came from — and every printed message is
byte-identical to before. A provider that is absent, refuses an operation, or
breaks its own contract throws instead, and core's fatal traversal ranks it
between a durability failure and a documentation failure, by identity and by
structural tag.

* 🐛 Keep core's error module out of the runtime's host graph

`errors.ts` is in the graph a separately loaded copy of `printErrors` bundles,
and importing the runtime's package root pulled the host Apis in with it —
including a native addon no bundler can inline. The Files recognizer needs none
of that, so it comes from the leaf module through a new `./files` subpath.

The five-target job prepares with `deno install` rather than `deno task deps`:
the task caches graphs this job does not need and reaches them by spawning a
child, which does not survive the Windows runner's path handling. Its compile
now carries the repository's isolation flags, and the rule that enforces them
reads every compile in a workflow rather than letting the first invocation's
flags answer for the rest.

* 🐛 Sort the search results with the lib the Node typecheck targets

`toSorted` needs es2023, which `tsconfig.node.json` does not select. The array
is built from a Set on the line above, so nothing shared is being mutated.

* 🔒 Make Files recognition total and strict, and keep cancellation cleanup fatal

Structural parsing runs from `fatalCause`, which every generic catch in
expansion consults. A provider is free to hand back a Proxy that refuses to be
inspected, and one of these parsers throwing would replace the failure being
classified with a failure about classifying it. Every read is now total, and so
is the cause traversal: a hostile wrapper narrows what discovery finds instead.

Recognizing a Files fatal is a decision to let that exact object travel onward
by identity, so it now requires the whole public contract — frozen data with no
extra fields, the fixed diagnostic for its kind, and no cause. A candidate
carrying a raw message or an errno chain is replaced by a fresh invariant
rather than preserved. Durability recognition is unchanged: it stays #394's
class-based mechanism, and only the Files boundary is crossed by a second
loaded copy.

A host cleanup that fails while cancellation is unwinding has no outcome to
report beside it. It leaves the scope as a fixed teardown invariant instead of
manufacturing a write result, carrying neither the platform's error nor the
generated temporary's name.

The loaded-copy claim is now proved by a real second copy: the Files module is
bundled, imported as its own module, and its failures are recognized in both
directions.

* 🔒 Rebuild every Files outcome from validated parts

Recognizing a Files fatal hands that exact object onward, so the contract now
covers the whole Error: the fixed name and diagnostic for its kind, frozen data
with exactly the kind's fields, no cause, and no other enumerable member —
string or symbol. A path riding on `name`, on an extra property, or under a
symbol key fails the contract, and `invokeFiles` replaces the candidate rather
than preserving it.

A `Result` is only conventionally a Result. The TypeScript signature is a claim
about the provider, not a guarantee, so a component that read `ok`, `value`, or
`error` first would be the thing that ran a hostile accessor — outside anything
that sanitizes. The core wrappers now inspect the container totally and rebuild
every outcome from validated parts: no provider-originated container, error
object, or payload reaches `<File>`, `<Glob>` or `<TempDir>`, and a search
result is copied rather than passed along.

A container that will not say how it settled, and a success it cannot describe,
are provider-contract failures. A malformed non-write failure is not: the
vocabulary already has a sentence for it, so a fresh generic failure is
substituted and the document carries on.

FA24 now runs all six orderings of the three fatal kinds, through both wrappers.

* 🔒 Tell an unreadable Result member apart from an absent one

`undefined` was standing for three different answers: a member read fine and
held undefined, a member was absent, and reading a member threw. Collapsing
them let two containers through that never described their outcome —
`checkFilePath` accepted a success whose `value` refused to be read, and a
non-write failure whose `error` refused was downgraded to the printable generic.

Presence and readability are now asked separately, and every operation says
which it requires. A search array is copied by index through the same reader,
so length and element traps are covered too and the iterator is never consulted.

The one place the contract bends is `checkFilePath`: Effection spells a
payload-free success as its shared `Unit`, `{ ok: true }` with no `value` member
at all, so absence there is the ordinary success rather than a failure. What is
refused is a `value` that cannot be read, and one that is present but is
something other than undefined.

The seam now passes live Proxies around real `Ok`/`Err` values through
`API.Files`, so the boundary is the first thing to run a hostile trap — the
previous JSON round-trip invoked the getter inside the provider handler and
proved only the already-covered handler-throw path.

* 🔒 Recognize a search result's array brand totally

`Array.isArray` is itself an operation on provider-controlled data: it throws on
a revoked Proxy. Running it outside the total readers let a raw TypeError leave
the boundary untagged, so a search whose result was revoked before it was
returned surfaced the platform's message and let the document carry on.

The brand check now answers instead of throwing, and a value whose array
identity cannot be inspected is malformed success data like any other. The guard
sits inside the payload contract rather than around the call site, so it stays
the thing under test: restoring the unguarded call reds the regression.

The specification catalog gains the rows for behavior already implemented —
HF12b and FF11 through FF15 — and the provider-failure prose now separates an
outcome that will not say what it is, which is a contract violation, from one
that reads fine and reports a failure this version does not recognize, which is
the generic sentence.

* 📝 State the non-write failure rule for every non-write operation

The provider-failure passages named only a read and a search, which left path
admission and TempDir unstated even though they take the same path: readable
data that does not validate selects the generic sentence there too.

The FF14 row described a stricter rule than the boundary implements. It now
matches FF14c and FF15: an unreadable settlement is a contract failure, so is an
unreadable selected member, and an absent success value only where the operation
carries one — path admission succeeds without one. A readable but unrecognized
non-write failure stays printable.

FF11 is renamed to what it asserts. Declining to recognize a hostile shape is
not recognizing it as a valid structural failure.

---------

Co-authored-by: Taras Mankovski <74687+taras@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant