Decisions made during implementation, including learnings discovered along the way.
This log records decisions in their historical context. For the current implementation contract, start at the architecture reading map. The vision is non-normative; language interfaces and conformance belong to the language specification.
| Topic | Decision and current owner |
|---|---|
| Mixed durable expansion and intentionally non-durable Operations | DEC-015 partially supersedes DEC-001 and DEC-002; DEC-034 still governs genuinely non-durable work. Current generated admission preserves both rules. |
| Plan information-request delivery | DEC-017 records sequencing; Plan architecture and the Plan interface own current behavior. |
| Main health and verification ownership | DEC-013, DEC-014, and contribution verification. |
| Withdrawn terminal-grid surface | DEC-016; installed structural syntax remains the extension boundary. |
Status: Decided
Date: 2026-03-08
Current scope: DEC-015
partially supersedes this decision for generated mixed expansion that yields
durable effects. The historical examples below remain as recorded; ephemeral()
is still the explicit adapter for genuinely non-durable Operations under
DEC-034.
@effectionx/durable-streams defines:
type Workflow<T> = Generator<DurableEffect<unknown>, T, unknown>;This is structurally incompatible with Effection's Operation<T>:
type Operation<T> = Generator<Effect<unknown>, T, unknown>;A Workflow<T> can only yield DurableEffect values (which get journaled).
An Operation<T> can only yield Effect values (Effection infrastructure).
These two generator yield types don't overlap — you can't mix them freely.
When writing durableImportComponent, the inner function passed to
createDurableOperation<T>(desc, execute) must be () => Operation<T> — it
runs inside Effection's structured concurrency and yields Effect values
(e.g., yield* runtime.readTextFile(path)).
But the outer function (durableImportComponent itself) must be a
Workflow<T> because it yields the DurableEffect returned by
createDurableOperation.
The expansion engine generators (expandSegments, expandComponent) are
neither pure Workflow nor pure Operation — they yield* into both
worlds (durable effects from import/exec, and non-journaled runtime work).
Use ephemeral() from @effectionx/durable-streams to bridge the gap.
ephemeral(op) wraps an Operation<T> so it can be called inside a
Workflow<T> — the operation runs but produces no journal entries. It's
the explicit escape hatch for non-durable work inside a durable workflow.
Pattern:
function* myWorkflow(): Workflow<string> {
// This yields a DurableEffect — gets journaled
const data = yield* durableCall("fetch", () => fetchData());
// This is an Operation — use ephemeral to bridge
const parsed = yield* ephemeral(parseData(data));
return parsed;
}For createDurableOperation:
The execute parameter is () => Operation<T> — inside it, you use
normal Effection operations (yield* runtime.readTextFile()). The
createDurableOperation call itself produces a DurableEffect<T> which
is yielded in the Workflow context.
This distinction should have been understood before starting implementation. The type system enforces a clear boundary between "journaled effects" and "infrastructure operations" — this is a feature, not a bug. It prevents accidentally mixing durable and non-durable yields.
Status: Decided
Date: 2026-03-08
Current scope: DEC-015
partially supersedes this decision for generated mixed expansion that yields
durable effects. The historical examples below remain as recorded; ephemeral()
is still the explicit adapter for genuinely non-durable Operations under
DEC-034.
expandSegments and expandComponent need to yield durable effects
(via durableImportComponent, exec modifier chain) and also perform
non-journaled work (interpolation, validation, parsing).
The expansion generators are typed as Workflow<T> since they yield
DurableEffect values. Non-journaled work (interpolation, validation)
is done as plain function calls — no yield needed since they're
synchronous and deterministic.
For any async/Operation work needed inside expansion (e.g., if we ever
need to call runtime APIs directly), use ephemeral().
Status: Decided (updated by DEC-006)
Date: 2026-03-08
Modifier middleware compose into chains. Terminal handlers (exec)
yield DurableEffect values (via createDurableOperation). Wrapping
handlers (silent) call next() which runs the inner chain.
Each modifier is a ModifierFactory that returns a
Middleware<[], CodeBlockWorkflow>. The CodeBlockWorkflow type is
Workflow<CodeBlockResult> — a generator that yields DurableEffect
values and returns a CodeBlockResult.
type CodeBlockWorkflow = Workflow<CodeBlockResult>;
type ModifierMiddleware = Middleware<[], CodeBlockWorkflow>;
type ModifierFactory = (params: string | undefined) => ModifierMiddleware;See DEC-006 for the full rationale behind this signature change.
Status: Decided
Date: 2026-03-08
Component resolution needs filesystem access (runtime.stat()).
The spec says resolution runs inside durableImportComponent's
createDurableOperation body, so resolve and read form a single journaled
operation.
The resolver function returns Operation<ResolveResult> (not Workflow),
because it runs inside the execute body of createDurableOperation
where only Operation yields are valid.
The durableImportComponent function is a Workflow — it yields the
DurableEffect from createDurableOperation.
Workflow context: durableImportComponent
yields DurableEffect: createDurableOperation(desc, execute)
Operation context: execute() — resolver, readTextFile
yields Effect: runtime.stat(), runtime.readTextFile(), etc.
Status: Decided
Date: 2026-03-08
@effectionx/durable-streams is maintained in this repository as an
experimental implementation detail. Its API may change.
Keep stream protocol integration in the execution boundary
(src/execute.ts, src/eval-handler.ts, and terminal effect handlers).
All other modules (scanner, frontmatter, expand, interpolate, validate,
render) have zero dependency on the stream package.
The expansion engine reaches import and modifier execution through the
contextual Component Api (DEC-012) — it doesn't know about durable
effects. The document's providers, installed by execute.ts, are
the only place expansion meets the stream protocol.
Status: Decided
Date: 2026-03-09
The original ModifierHandler type was:
type ModifierHandler = (
context: CodeBlockContext,
params: string | undefined,
next: () => Workflow<CodeBlockResult>,
) => Workflow<CodeBlockResult>;This had two problems:
-
contextwas a handler parameter. Effection has a first-classContextsystem for scope-inherited values. Passing the code block context as a function argument bypassed this — every handler received data it might not need, and the signature didn't match Effection's middleware shape. -
The signature didn't match
Middleware<TArgs, TReturn>. Effection v4.1's Api system uses(args: TArgs, next: (...args: TArgs) => TReturn) => TReturn. Our three-argument(context, params, next)was a custom shape that couldn't be composed with the samecombine()primitive.
Three changes:
1. Reusable middleware primitive (@effectionx/middleware):
type Middleware<TArgs extends unknown[], TReturn> = (
args: TArgs,
next: (...args: TArgs) => TReturn,
) => TReturn;Plus a combine() function matching Effection's api-internal.ts.
This is decoupled from modifier-specific types and reusable for any
future middleware scenario. Originally implemented as src/middleware.ts,
this was extracted to the @effectionx/middleware shared package.
2. Factory pattern for modifier registration:
type ModifierMiddleware = Middleware<[], CodeBlockWorkflow>;
type ModifierFactory = (params: string | undefined) => ModifierMiddleware;Each registered modifier is a factory. When the chain is composed,
the factory is called with the parsed params from the info string
(e.g., "brief" from sample=brief). The returned middleware
conforms to Middleware<[], ...> — no arguments flow through next,
params are captured in the factory closure.
3. Contextual delivery of code block metadata:
The block's CodeBlockContext is delivered through the Component Api
(DEC-012): composeModifierChain installs a scope-local codeBlock()
provider for the duration of the chain, and handlers that need the
code block info (language, content, componentName) call useCodeBlock()
instead of receiving it as a parameter. The ephemeral() bridge makes
this work inside durable workflow generators (see DEC-001).
A provider installed inside the chain's own scope is removed when that scope exits. This is the correct primitive because:
- Each code block gets its own context for the duration of its chain
- No context leaks between code blocks
- The chain runs inside
ephemeral()which bridges the Operation world (where Context lives) with the Workflow world (where durable effects are yielded)
When building middleware systems, start from the target middleware type
(Middleware<TArgs, TReturn>) and derive the domain-specific types
from it — not the other way around. The original design started from
the domain (what does a modifier handler need?) and ended up with a
custom shape that didn't compose with the rest of the framework.
Status: Decided
Date: 2026-03-10
The provider component pattern (spec §6.7) uses when() from
@effectionx/converge to poll a daemon's readiness endpoint. The
original readiness check was:
yield* when(function* () {
const response = yield* fetch(baseUrl + '/health');
if (!response.ok) throw new Error('Not ready: ' + response.status);
}, { timeout: 5000, interval: 50 });@effectionx/fetch provides a .expect() method that throws
HttpError on non-2xx responses, making the manual response.ok
check redundant.
Use fetch(url).expect() inside when() for readiness polling:
yield* when(function* () {
yield* fetch(baseUrl + '/health').expect();
}, { timeout: 5000, interval: 50 });Why this works:
.expect()throwsHttpErroron non-2xx status codes- Network-level errors (connection refused before daemon is listening)
throw natively from the underlying
globalThis.fetch when()in non-always mode catches any thrown error and retries at the configured interval until the assertion passes or timeout expires.expect()returns a freshFetchOperationper iteration — no leftover state or dangling resources between retries
Convention: All provider readiness checks should use
fetch().expect() rather than manual response.ok checks. This
keeps the pattern concise (one line instead of three) while preserving
identical behavior.
Status: Decided
Date: 2026-03-11
Eval blocks needed a way to produce rendered output (for the Sample component pattern). Two approaches were considered:
yield* output("text")— an Effection operationoutput("text")— a plain function call
output() is a plain synchronous function. It mutates a block-local
outputRef object. The output text is journaled alongside exports as
__output in the eval operation result.
Why plain function: Output is a synchronous side effect (setting a string value), not an async operation. Making it a generator would require the block to be in generator mode just to set output, which is unnecessarily restrictive. A plain function keeps the API simple.
Why __output in exports: Avoids a separate journal entry type for
output text. __output is extracted before merging into env.values to
prevent namespace pollution.
Status: Decided
Date: 2026-03-11
Components needed a way to capture their children's rendered output (for the Sample component). Two patterns were considered:
- A Render Api with middleware installation
- Closure functions injected into
env.values
Closures injected into env.values at component expansion time. They
capture the expansion context (meta, props, hide set, eval scope) and
install the caller's binding environment and eval scope as scope-local
Component providers (DEC-012) around their expandSegments calls.
Why closures, not an Api: A Render Api would require middleware
installation per component and add a new Api type to the system.
Closures are simpler — they're injected once during expandComponent
and naturally capture all needed context. They're non-serializable,
so serializeExports silently omits them from the journal.
Why provider installation inside the closure: The closures may be
called from inside evalScope.eval(), where the ambient scope differs
from the expansion scope. Installing the providers at call time ensures
the correct environment and eval scope are visible regardless of the
calling task.
Status: Decided Date: 2026-03-11
The Sample component has three optional props: prompt, model,
params — declared in the props schema but absent from its required
array. The component keeps a complete, predictable validated props object
for its body and routing semantics.
All three props declare default: "" in the props schema. The eval block
converts empty strings to undefined for routing semantics:
model || undefined, params || undefined.
Why empty string, not undefined: an optional property with no
default never adds the key to validated props. Empty string is a
legitimate default that ensures the key exists. The || undefined
conversion preserves the distinction between "not provided" and "empty"
for provider routing (providers check context.model !== undefined).
Status: Decided
Expansion originally received its dependencies through an
ExpansionContext object (importComponent, runModifierChain)
threaded as a parameter into every recursive expansion call, while the
remaining operational state traveled through raw Effection context keys
(EvalEnvCtx, EvalScopeCtx, CodeBlockCtx, PersistFlagCtx,
ContentCtx, ErrorModeCtx). Two delivery mechanisms meant two
override models: replacing an import strategy required constructing a
new container at the call site, while overriding scope state required
knowing which raw key to set — and neither surface was wrappable for
instrumentation.
One public Api — Component (ComponentApi) backed by
@effectionx/context-api — carries every context-dependent operation:
importComponent, applyModifiers, raise, env, evalScope,
codeBlock, persistent, content. The ctx parameter is gone from
the expansion signatures, and the raw operational keys are removed from
the public surface. useCodeBlock() and useContent() remain as
ergonomic aliases.
Why middleware at min for implementations: middleware installed
in a nested scope runs before inherited middleware, so a component's
own env/evalScope providers shadow its ancestors' by
short-circuiting — and installing inside scoped() removes them on
exit, so siblings never observe each other's state. Runtime providers
(document import, modifier execution, per-component state, error
mode) all install at min.
Why max for instrumentation: max middleware wraps outside every
implementation, so tracing, mocking, and overrides can observe or
transform calls without caring which min provider is active, and can
delegate with next(...) or short-circuit.
Why defaults instead of mandatory providers: operations with a
sensible neutral value default to it (raise returns the segment,
env/evalScope are undefined, persistent is false), while
operations that cannot proceed without a provider (importComponent,
applyModifiers, codeBlock, content) throw named missing-provider
errors that identify the missing installation.
Durable-streams' own contexts (e.g. DurableContext) are unchanged: they
store durable runtime state, not overridable core operations.
Status: Decided (extended 2026-08-18) Date: 2026-08-05
The main ruleset (id 13643935) carries deletion, non_fast_forward, and
pull_request, and requires the single aggregate check green. When this
decision was first taken it required no status checks at all, so a failed CI
run on main blocked nothing, notified nobody, and sat until someone opened the
Actions tab.
The cost of that is not hypothetical. main was red for two consecutive runs
on 2026-08-04 with nothing said, and it was found by someone reading the Actions
tab for an unrelated reason (#332).
Three mechanisms could close the gap, in ascending strength: notify on the
failed main run, require status checks on pull requests, or run the checks
against main + the pull request in a merge queue before landing.
Notify, by opening an issue. .github/workflows/main-health.yml keeps one
ci-main-red issue in step with main: opened on red, commented on repeated
red, commented and closed on recovery. It is assigned to the pusher, falling
back to taras when that actor cannot be assigned, so the report always
reaches a person.
An issue rather than a chat message or an email: it has an owner, a thread, and a state that a later green run can clear. It also needs no secret, so it works from a fresh clone of this repository.
It reconciles; it does not react. Every completed CI run wakes it and none
of the delivery's verdict is read. workflow_run delivery is unordered, a
re-run delivers an old conclusion, and a delivery filtered on its own conclusion
would still consume the workflow's one pending concurrency slot and then
reconcile nothing — so a cancelled delivery could evict the only pending
failure. The report is decided from the run the workflow selects instead:
current head, push to main, highest run number, then highest attempt.
Three properties follow, and each is the answer to a way the first design broke:
- Markers are trusted by author. The run and attempt a report covers is written into the artifact that reports it, and read back only from artifacts the workflow itself wrote. Issue comments are public, so trusting a marker by content would let any commenter name the current run and suppress a real report. An unreadable marker comments rather than staying silent.
- Assignment reconciles on its own terms. Tying it to report content leaves a failed assignment permanently unretried, because the marker written by the same action then says there is nothing to do.
- A failed read fails the run. Reading "no authoritative run" out of a query
that failed would make an outage indistinguishable from a green
main. - The reporter runs trusted code only. It holds
issues: write, so it checks outmainrather than the ref that woke it — everything it executes has to be code that was already reviewed. The gate below is the other side of that rule: it holds no write permission at all, so it runs the pull request's own checkout, and stays changeable by the pull request that changes it.
Required status checks: one, not eight. GitHub's rule matches check-run
names, and every job is its own check run; there is no built-in check meaning
"the CI workflow passed". Listing the jobs would put a hand-maintained copy of
ci.yml's job list in repository settings, where a job added and not listed is
silently ungated and nothing reports it. The ruleset therefore requires one
aggregate check, green, and ci.yml's own regression test holds green.needs
to every job in the file.
green requires each job to produce the result its event calls for. "Succeeded
or was deliberately skipped" was the original rule and it became too weak the
moment a job was conditional: it read a job that never ran as satisfied. So a job
that always runs must succeed outright, and each conditional job is required
exactly where it runs.
Reporting is not gating, so main-green gates. An issue reaches a person; it
does not stop the next pull request merging onto the base that broke. That is the
worse half of the failure, because work then lands on an unproven main and the
next failure cannot be told apart from the one already open. The main-green job
withholds green from an ordinary pull request until CI has completed
successfully for main's exact current head.
Exactness is the whole claim. A successful run for the previous head is precisely
the state a red main is in one commit after it broke, so an inexact gate would
pass in the case it exists for. The gate therefore selects the authoritative run
with this decision's own rules — selectAuthoritative(), shared with the
reporter rather than reimplemented — and reads main's head a second time after
inspecting the run, because a head that moved in between means the verdict is
about a commit that is no longer the base. Only a completed run that concluded
success, for a head the second read proves is still current, passes.
Waiting is what makes exactness affordable. Answering once put the cost of
main's own build on every pull request opened during it: no verdict had
arrived yet, so the gate failed and asked the author to re-run the job by hand.
Absence of proof was being reported as a main-health verdict. So the gate
converges on one instead — when() from @effectionx/converge, polling every
fifteen seconds. Absent, queued and in-progress runs are states to wait through;
an advancing head is followed rather than reported as a race, and every
conclusion read about the head main has left is discarded, because a stale
failure is no more current than a stale success. A completed unsuccessful run
for the still-current head fails immediately, and a read or parse failure fails
immediately too — neither is something a later poll improves.
The job carries timeout-minutes: 60, which is roughly twice a full main run,
and the waiter gives up just inside that so the log names what it was still
waiting on. Reaching either bound means this pull request obtained no proof. It
is deliberately not a MainNotGreen verdict: nothing about a wait that ran out
says CI failed. The repair label is read before any of this, so a
ci-main-red-fix pull request neither reads main nor waits on it.
The gate holds contents: read and actions: read and nothing more. It decides
a required check, so it must not be able to write an issue, a pull request, or
the repository.
It answers about main and not about the branch, which is the other half of the
ruleset's job: main-green proves the base is healthy, and the ruleset's strict
required-checks policy proves the branch is on top of that base. Neither claim
substitutes for the other, and a gate that read the pull request's merge base
instead would pass on a base main had already left behind.
Repair takes a label, and the label buys one lookup. A pull request that
restores a red main cannot satisfy the gate by construction: the base it would
prove is the broken one. A maintainer applies ci-main-red-fix to one pull
request, and it is excused the remote main-health lookup and nothing else — the
labelled pull request runs composability, the clean-checkout proof a main
push runs, and green fails if that job fails or is skipped. So the branch
claiming to repair main is proven the way main itself is proven.
This is deliberately not a maintainer bypass. No actor, branch name, commit
message or other label grants the exception, the label is visible on the pull
request and in the run's log, and removing it recomputes green as an ordinary
pull request — which is why ci.yml's pull_request trigger lists labeled and
unlabeled. The ruleset's own general bypass is removed for the same reason: an
exception that leaves no trace and proves nothing is the mechanism this decision
was taken to avoid.
Merge queue: still not now. It is the only mechanism that makes "every commit
provides a clean slate" a guarantee rather than a practice, and it costs a full
serialized run per merge. That tax scales with the slowest job, and composability
is roughly eleven minutes on its own (#331). main-green buys most of the
guarantee for none of the tax: it cannot prove a pull request against the merge
result, but it does prove the base, which is where the observed breakage came
from. The condition that would change this is unchanged and now measurable — if
the ci-main-red issues show main breaking often enough that a serialized run
per merge is cheaper than the breakage, adopt it.
The gap is not contingent on composability moving to main only. That move is
what would make main the sole place the concurrency and cache-purity
invariants ever execute, and it is not in #313 as it stands — but a red main
is already invisible today, and the notification is correct either way.
deno task verify was the whole applicable battery run at once, and
composability ran that battery again after a clean clone. The load was the
argument: eleven commands reading one node_modules was the closest available
proof that nothing there was owned by a build.
Measured, it was mostly not that. At 5542c92 the complete Deno, Node and Bun
suites consumed about 26 of the job's 31 minutes — the same three suites
test-deno, test-node and test-bun already run in parallel on runners of
their own. Compressing them onto one four-core runner made each slower and
produced the composability-only 45m/30m/30m deadlines, one of which killed a
suite that had passed (#538).
Load is not evidence. No arbitrary test file proves an ownership boundary by happening to run for a while; it would have to resolve at the wrong instant, and nothing arranged for it to. So the claim is narrowed to the one the job exists to protect:
After preparation, build and verification operations do not corrupt or replace shared repository-owned state while supported runtimes consume it.
and the conflict is staged directly. deno task build:web is the real producer
— the one path that reads both @rjsf/validator-ajv8 manifests, resolves
through the installed tree, and republishes the generated browser module. Deno,
Node and Bun each run the same portable consumer loop against it, resolving and
reading a package from the pnpm store, the Deno store and the workspace and
importing the generated module, continuously, for the producer's whole lifetime.
An observer watches the manifests and the generated output from the coordinator.
Three checks, because no one of them can replace the others. A build that
replaced a manifest and put the original bytes back compares equal at the end
and has still broken every runtime that resolved through it in between — so the
manifests are held to byte and identity while the producer runs, and a final
fingerprint is never accepted as proof that a temporary mutation did not happen.
The generated module is held to a different rule, because replacing it is the
producer's job: every reading must be a whole module, and it is published
through replaceThroughStaging() so no reader can see a partial one. And the
comparison of tracked files, node_modules and deno.lock still runs
afterwards — including after a participant failed, which is exactly when a
dirtied tree would go unnoticed.
A consumer that exits zero without ever cycling beside the producer is a hole, not a pass, so each records how many cycles it completed before, during and after, and the report fails on a zero.
Correctness stays where it already was. Every check composability stopped
running is still required by green: each runtime's complete corpus across the
test-deno, test-node and test-bun shard matrices; the Deno typecheck as a
step of test-deno and the Node one as a step of test-node; lint and jsr
as jobs of their own; the documentation check as a step of smoke; and the site
check/build pair in site. Several of these are steps inside other jobs rather
than jobs, and one runtime's corpus is a matrix rather than one invocation —
which is the point: the requirement is what green enforces, not the shape of
the workflow. Composability runs none of them, and deno task verify is not a
correctness check; describing it as one is what made the duplication look
free.
The suite-specific deadlines disappear with the suites rather than transferring
to the consumers: the consumers are bounded by the one producer lifetime, not by
how long an application suite takes. What remains is the single 20-minute
ceiling the historical deno bundle wedge (denoland/deno#36417) requires, which
also bounds the wait for the consumers to reach their first cycle — a consumer
that never gets there wedges the invocation the same silent way.
Status: Decided
Date: 2026-08-25
A retained generated_xmd admission holds the normalized policy it was granted
under, and a continuation was compared against it whole and exactly — the
Workspace roots and selected root included. But those two fields are values of
the run's own mutable progress: every committed Workspace mutation retains
another immutable root and advances the authoritative current root, and
<Evaluate> restates its policy from live run storage at every invocation. So
any resume through an admission followed by a Workspace commit — including the
commit the admitted fragment's own write performs — restated a grown root set,
failed the exact comparison, and refused with CEILING. A policy derived from
mutable run state can never be restated identically by a partial continuation
whose state has legitimately advanced.
Separately, the evaluator crossed from the durable Workflow typing to
Operations through ephemeral() bridges, ephemeral(expand(...)) among them —
an adapter DEC-034 reserves for intentionally non-durable Operations, wrapped
here around an expansion that yields ordinary durable effects.
An admission's roots and selectedRoot remain exact as-of-admission
provenance in the retained record — no shape or migration change — and a
continuation checks them as a retained basis, by membership: every
admission root and the admission's selected root must still be retained, and
the current selected root must be retained, while additional roots and an
advanced current root pass. Loss of any admission root refuses before generated
work, with the unchanged fixed diagnostic. Every non-root term — class
selection, selected pinned identities and forms, request ceilings — still
compares whole, exactly, and in canonical order. This supersedes DEC-001 and
DEC-002 only where a generated mixed expansion can yield durable effects: the
evaluator chain (persistAdmission, evaluateGeneratedXmd,
evaluateGeneratedFragment, the host's admit()) is typed Operation, and no
ephemeral() bridge encloses it, so the admission and every nested generated
effect belong to the owning expansion's own durable sequence. ephemeral()
remains the valid, explicit adapter for truly non-durable Operations. The
current generated-admission contract
preserves that distinction.
Removing the bridge is DEC-034 conformance, not the continuation
correction: the reproduced WGAC16 resume failed identically with and without
it, and exact journal order does not distinguish the bridge, because a re-run
expansion re-offers the retained sequence either way. The observed defect was
the exact root comparison rejecting the run's own progress, and the membership
basis is what fixes it. No durable-stream protocol, journal or record schema
changes, and no child durable coroutine exists: replay evidence is GX18a–d and
GX22/GX22b–d in packages/core/tests/generated-xmd.test.ts and WGAC16's
journal- and root-publication-stability snapshots in
packages/cli/tests/workflow-suspension.test.ts, where an API.Files call
count is explicitly not once-only evidence — document re-expansion legitimately
enters that boundary before the durable effect underneath restores.
Status: Decided
Date: 2026-09-15
The #729 implementation reserved <Terminal.Grid> and <Terminal>, published
them through syntax inspection, validated their authored structure, and derived
a row-major layout. It installed no provider: every otherwise valid invocation
ended with a provider-unavailable error before any pane ran. #734 nevertheless
recorded the complete #717 terminal-grid and tmux design as an active contract.
No release contains those commits, no provider implementing the contract has merged, and no released durable terminal-grid record requires compatibility. The accepted names and ownership also changed while the unmerged implementation was developed. Keeping the stub would turn an implementation placeholder into a public compatibility burden before the product has established the interaction model it needs.
<Terminal.Grid> and <Terminal> are not XMD structural syntax. Core does not
reserve, validate, expand, place, journal, or replay them, and syntax inspection
does not advertise them. There is no alias or migration reader for the
unreleased provisional surface.
The terminal-grid lifecycle, pane launcher, pane-terminal ownership, and tmux provider contracts are withdrawn from the active architecture and executable specifications. The #726 spike remains historical engineering evidence only; it does not define current product behavior.
Root <Session.Launch> is unchanged. It continues to own the one foreground
terminal through the established native-launch, session-identity, durability,
and teardown contracts. Codex and Claude support remain independent of any grid
or multiplexer.
The generic installed-structural boundary remains the only extension mechanism for structural syntax supplied outside core. This decision defines no replacement grid, pane, multiplexer, session viewer, or REPL interface.
A document using either withdrawn name receives ordinary component resolution: an installed or repository component may claim it, and an unresolved name is a missing component rather than a terminal-grid diagnostic. No durable migration is necessary because the mainline stub never produced a grid journal record and was never released.
Removing the surface is surgical rather than a commit revert. The unrelated catalog corrections that landed with #729, the generic structural installation work from #806, and every root native-launch change remain.
Status: Historical delivery record; current rules remain in the architecture and Plan specification.
Date: 2026-09-07 (the accepted Evaluate forms and ordinary-output changes,
d56d5ba016150b7750384ba4a2ba81f56a5b1ab9 and
426df00656a9a38087e2c961454a9b8015161f4a).
The following amendment and sequence were recorded during delivery. References
to this stack describe that historical sequence, rather than prerequisites for
current use. The later 2026-10-14 correction
cdcfe328f1c8ec0cd7a62329a99d17b7edd60d1e preserves ordinary host-governed reads
throughout the trusted Plan frame while refusing mutations.
This section records the Product Owner's #762 amendment against
1a7b96ab6c75f29fcf82513017f6daabab4d0d82. PR #776 delivers protected Syntax
routing and PR #786 the ordinary generated output and as bindings the requests
compose with. The contracts below replace the earlier Planner artifacts'
Syntax-only child execution, prohibition on file reads, output-only context, and
profile-owned evaluation limits.
Everything below is delivered on this stack. The shared ordinary read profile —
xmd run and every host="run" child stating one profile whose read table is
canonical self-closing <File />, canonical self-closing <Glob /> and exact
canonical protected self-closing <Syntax />, with the write table unchanged.
Every built-in structural construct inside generated evaluation, with ordinary
semantics and whole-fragment preflight across every alternative and nested body.
The lexical response classifier, the eight-request loop the packaged <Plan>
owns, and the narrow typed recovery around the public throwing <Evaluate>.
After #776 and #786, delivery is three independently reviewed PRs, all of them on this stack:
- Shared read profile. Canonical self-closing Glob and canonical protected
Syntax join self-closing File in the ordinary
readtable, through their captured capabilities and the existing component-answer route, preserving ordinary value, binding, persistence and replay behavior. The shared write table is unchanged. - Generated structural constructs. Every built-in structural construct becomes available inside generated evaluation with its ordinary semantics, declarative expressions and fragment-local bindings. Preflight validates every branch and nested body before any effect, and every effectful component reached through a construct stays limited by the selected permission.
- The Plan loop. Both command hosts install the shared profile, and Plan gains the classifier, prompts, narrow typed recovery, counters, records, progress, disclosure, journal behavior and the source/npm/compiled journeys.
No temporary Plan-private evaluator bridges them.
#787 owns question-based
selection and request-aware rewriting of relevant Syntax documentation; explicit
names lookup here does not wait for it.
#788 owns the general
<Timeout> component, which is why this design adds no Plan-local deadline.
#777 owns the later workspace
selection and user-approval escalation policy. Existing host containment remains
in force with its stable-namespace limitation; #227 owns concurrent
path-replacement hardening. Deferring those decisions does not defer File or
Glob reads. #679 remains separate evaluation work after product delivery.