From 86c10507afba2d166065f76ec41c754e63ebff1e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 15 Oct 2026 12:35:14 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=94=92=20Offer=20generated=20componen?= =?UTF-8?q?ts=20as=20one=20contextual=20selection=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trusted host used to state what a generated fragment may do as a whole `ExecutionInstallation.evaluation` profile: two contract tables of pinned definitions, a Workspace basis read per invocation, a frozen Fetch timeout and an exact request inventory. Most of that restated facts the components themselves already owned, and the parts that did not — the retained roots and the request allowlist — were live state a continuation was then compared against. A host now offers one thing: `Component.generated`, an ordinary contextual value property mapping component name to the authored forms it offers. Core reads that answer once, after initialization and the captured installation callbacks and before public `Execution.execute` policy, validates it whole, resolves each selected name once through the ordinary import chain, and retains the implementation with its declaration's metadata. Which identity a spelling runs under, which class it reaches, what its props are validated against and what documents it all come from the declaration that owns the implementation. Before: - a host wrote `evaluation: { read: [fileReadEntry(), …], write: […] }`, and two installations stating one refused; - selecting `Glob`, `Fetch` or `Elicit` ran a body pinned past whatever the host had legitimately registered for that name; - `` carried an exact request inventory that preflight classified the element against, and the admission retained it; - the admission retained the run's Workspace roots and compared a continuation against them. After: - a host composes `Component.generated` middleware; two handlers compose, and an empty or malformed selection refuses setup rather than being defaulted; - every selected name resolves to the actual host answer, and a replacement offering no generated use of the selected spelling refuses setup; - a destination bound is a live `Permissions.fetch` restriction in the owner that performs the request, asked of the transformed request immediately before transport, so a dynamic destination is an ordinary input to preflight. The attachment reads what the host stated once and derives both halves from that copy, so the reading that offers `` is the reading whose bound was installed. The bound is the complete request — destination, method and every header as it will be sent — and a request or a bound that names one header twice is refused rather than normalized, because a transport joins differently cased occurrences into one sent header that neither of them stated; - the `generated_xmd` record is marker 4: the selected rows and the class selection, with no Workspace basis and no request inventory. Marker-3 records are refused as unreadable history. How it works: host middleware answers Component.generated -> invoke(): init, installation callbacks, documentation -> assembleComponents(): registry, bundle, declarations, protected tier, identity domains -> admitGeneratedSelection(): one read, validated whole -> resolveSelectedComponents(): one import per name, published where the answer was produced -> captureEvaluationProfile(): read/write tables derived from each owner's generated metadata -> public Execution.execute policy -> executeDocument() reuses that same assembly Canonical component assembly moved out of `executeDocument()` into the invocation owner so the selection can resolve against the host environment before policy runs; authored execution reuses the result rather than building a second one. What that assembly does not decide is where the document's own imports resolve from: those still go through the options the canonical request settled to, so a policy's legitimate `request.withOptions()` search-path replacement reaches authored imports and this execution's own description, while the capture keeps the paths it resolved against. Removed: `FragmentEvaluationInput`, `ExecutionInstallation.evaluation`, `statesEvaluation`, `TWO_PROFILES`, the entry constructors, `src/fragment-capabilities.ts`, `workspaceRootSelection`, the generated `fetchTimeout` capture, and the `generated-observations.ts` wrapper whose only consumer was its own tests. The refusal normalization that module also owned — an admitted component's ordinary `Err` becoming a marked generated-request refusal with a safe reason — moved into the capture guard. Hosts migrated together: ordinary CLI run, REPL entries (the selection is data on the profile, offered in each entry's own execution scope as the ordinary fallback, so a plugin's or a host installation's own selection is the one that entry runs under), isolated `host="run"` children, the Plan writer, and the Workflow attachment, which offers its selection inside `host.attach` and states its optional request bound beside it. Every ordinary host's default goes through one helper that delegates first and answers only on `undefined`. --- architecture.md | 314 ++-- packages/cli/src/cli.ts | 15 +- packages/cli/src/evaluation-profile.ts | 145 +- packages/cli/src/plan-writer-profile.ts | 20 +- packages/cli/src/repl-profile.ts | 19 +- packages/cli/src/repl/expansion.ts | 24 +- packages/cli/src/repl/program.ts | 1 + packages/cli/src/repl/session.ts | 28 +- packages/cli/src/testing-host.ts | 14 +- packages/cli/src/workflow.ts | 48 +- packages/cli/tests/evaluate-component.test.ts | 73 +- packages/cli/tests/evaluate-workflow.test.ts | 80 +- .../repl/issue-881/controlled-host.ts | 5 +- packages/cli/tests/fixtures/repl/reference.ts | 5 +- .../workflow-agent/approved-proposal.md | 6 +- .../cli/tests/plan-command-document.test.ts | 115 +- packages/cli/tests/plan-component.test.ts | 136 +- .../cli/tests/repl-agent-execution.test.ts | 8 +- .../cli/tests/repl-agent-interface.test.ts | 19 +- packages/cli/tests/repl-agent-journey.test.ts | 19 +- packages/cli/tests/repl-execution.test.ts | 77 +- packages/cli/tests/repl-forms.test.ts | 5 +- packages/cli/tests/repl-journey.test.ts | 13 +- .../cli/tests/repl-presentation-style.test.ts | 5 +- packages/cli/tests/workflow-agent.test.ts | 40 +- .../cli/tests/workflow-installation.test.ts | 42 +- packages/core/api.ts | 1 + packages/core/host.ts | 122 +- packages/core/mod.ts | 3 +- packages/core/src/component-api.ts | 37 + packages/core/src/components/Evaluate.ts | 33 +- packages/core/src/components/components.md | 25 +- packages/core/src/evaluation-profile.ts | 1300 +++++------------ packages/core/src/execute.ts | 600 ++++---- packages/core/src/fragment-capabilities.ts | 489 ------- packages/core/src/generated-selection.ts | 146 ++ packages/core/src/generated-xmd.ts | 510 ++----- packages/core/src/syntax-admitted.ts | 56 +- packages/core/tests/all.test.ts | 18 +- .../core/tests/evaluate-component.test.ts | 439 +++--- .../core/tests/evaluate-loaded-copy.test.ts | 23 +- .../tests/evaluate-provider-lifetime.test.ts | 76 +- .../core/tests/evaluation-profile.test.ts | 718 ++++----- packages/core/tests/execution-init.test.ts | 22 +- .../core/tests/generated-composition.test.ts | 49 +- .../tests/generated-owner-metadata.test.ts | 9 +- .../core/tests/generated-selection.test.ts | 815 +++++++++++ packages/core/tests/generated-xmd.test.ts | 647 ++------ .../shared-operation-permissions.test.ts | 149 +- .../core/tests/support/generated-selection.ts | 38 + packages/core/tests/syntax-api.test.ts | 5 +- packages/core/tests/syntax-component.test.ts | 30 +- .../core/tests/syntax-loaded-copy.test.ts | 13 +- packages/git/mod.ts | 2 +- packages/git/src/composition/definitions.ts | 23 +- packages/workflow/deno.ts | 17 +- .../workflow/src/deno/workspace/effect.ts | 30 - .../workflow/src/deno/workspace/evaluate.ts | 323 ++-- .../workflow/src/generated-observations.ts | 116 -- .../tests/generated-agent-component.test.ts | 211 +-- .../tests/generated-observations.test.ts | 639 +++++--- specs/executable-mdx-spec.md | 247 ++-- specs/repl-spec.md | 7 + specs/workflow-workspace-spec.md | 182 ++- 64 files changed, 4571 insertions(+), 4875 deletions(-) delete mode 100644 packages/core/src/fragment-capabilities.ts create mode 100644 packages/core/src/generated-selection.ts create mode 100644 packages/core/tests/generated-selection.test.ts create mode 100644 packages/core/tests/support/generated-selection.ts delete mode 100644 packages/workflow/src/generated-observations.ts diff --git a/architecture.md b/architecture.md index dc79fd543..e3fdfeb8b 100644 --- a/architecture.md +++ b/architecture.md @@ -3447,11 +3447,27 @@ explicit document failure. `` is canonical core's own protected component, reserved ahead of every host and author tier, so the name means the same thing in every execution and no registration, repository file, bundle member, declared Markdown component, import handler or second loaded copy -answers for it. A host does not supply the component; it supplies the -*ceiling*, as one `ExecutionInstallation.evaluation` profile captured by value -before any installation runs. An execution accepts one and refuses two, and one -that was offered none has no evaluation at all — which `` says when it -is written rather than at startup. +answers for it. A host does not supply the component; it offers the +*selection*, as the ordinary `Component.generated` contextual property — a map +from component name to the authored forms that name is offered in. Normal +composition answers it, so two handlers compose rather than conflict and there +is no contributor count to refuse on; a host assembly is responsible for +composing the value it intends to offer. A host that offers none has no +evaluation at all — which `` says when it is written rather than at +startup. + +Core reads that answer once, after initialization and the captured installation +callbacks have assembled the host and before public `Execution.execute` policy. +It validates all of it — at least one name, every name a component name, every +value a canonical form declaration — and refuses setup whole rather than +repairing, filtering or defaulting any part: no member of an invalid selection +becomes an executable partial allowance, and no policy, root import, paired +producer, fragment body or generated admission runs afterwards. Then it resolves +each selected name once, through the complete ordinary +`Component.importComponent` chain and the canonical terminal behind it, and +retains the implementation together with its declaration's metadata. A later +policy that replaces a registration, imports a different body or offers a +broader selection changes neither. The document writes the program as `text`, or renders it as paired content: @@ -3555,30 +3571,32 @@ for the answer it produced and verifies it at the call site — so `Component.importComponent` middleware may observe or refuse a generated import and cannot answer, replace or mutate one. -**The trusted host** owns the ceilings, as values it holds and passes: which -retained Workspace roots exist and which one is selected, which pinned -identities are in the `read` table and which in the `write` table, which -authored forms each is admitted for, and which exact requests each may perform. -Generated source receives none of those values, and nothing contextual carries -them. +**The trusted host** owns the selection: which component names a fragment may +write, and in which authored forms. Nothing else. Which identity each spelling +runs under, whether it observes or mutates, what its props are validated +against and what documents it are all the owning declaration's, read off the +implementation canonical resolution answered with. Which destination an admitted +`` may reach is a live question the host asks where the request is +performed, as a `Permissions` restriction, rather than an inventory the +admission retains. Generated source receives none of it. An authored `allow` selects a canonical, non-empty, duplicate-free subset of -the closed classes `read` and `write` from what the host already installed; +the closed classes `read` and `write` from what the selection already reaches; omitting it selects `read` alone, so a document that states no class asks for -read effects alone. It narrows and never grants: it cannot mint an identity, add -a root, authorize a destination, attach a credential or widen a request, and a -class the host stated no table for is refused before the candidate is parsed. +read effects alone. It narrows and never grants: it cannot mint an identity, +authorize a destination, attach a credential or widen a request, and a class no +selected spelling reaches is refused before the candidate is parsed. `allow` and bindings are independent: effect selection does not restrict the language's ordinary fragment-local `as` behavior or pure composition. **A name is not a form.** A component whose two spellings do different things is -two identities, so one name holds two entries with disjoint form domains and one -shared implementation: `` resolves only to the pinned read identity and -`…` only to the pinned write identity. That choice is made in -preflight from the scan, before the first effect, so a form the selection holds -no exact entry for refuses the whole fragment. Overlapping forms or two -definitions under one name are a malformed host table rather than an ambiguity -settled by table order. +two identities, so one selected name holds two rows with disjoint form domains +and one shared implementation: `` runs under the read identity its +declaration offers that spelling and `…` under the write one. That +choice is made in preflight from the scan, before the first effect, so a form +the selection does not hold refuses the whole fragment. A spelling whose owner +offers no generated use of it refuses at capture, before an execution holds a +profile at all. Which identity was admitted is preflight's and is not answerable by anything else, and the body the admitted component runs is held to that same decision by @@ -3593,14 +3611,13 @@ import with, which collects what a read returned and is not the form dispatcher. an admitted reading `` does not become a write and an admitted writing `…` does not become a read, however many times something is asked. -The standard Deno workflow profile's write table is three entries, in the order -it states them: core's paired ``, the paired `` the bundled Git -Plugin asks for — the same implementation canonical core owns, under the -identity core's own declaration offers that spelling as a write, so the grant -and the authored component cannot drift — and core's self-closing -``. What the Git Plugin decides is that a workflow run admits the -name at all; an ordinary run admits none. A host's own extensions come after -them. Selecting `write` intentionally authorizes Dir's +The standard Deno workflow run selects three writing spellings: core's paired +``, the paired `` the bundled Git Plugin asks for — the same +implementation canonical core owns, under the identity core's own declaration +offers that spelling as a write, so the grant and the authored component cannot +drift — and core's self-closing ``. What the Git Plugin decides is +that a workflow run offers the name at all; an ordinary run offers none. A +host's own extensions are names it answers for. Selecting `write` intentionally authorizes Dir's persistent recursive directory creation as well as the mutations File and File.Delete perform. Each is the ordinary component and crosses `API.Files` into the run's transaction-bound provider, so an ensured directory is retained @@ -3614,49 +3631,47 @@ are outside the class. The identity a spelling runs under is a grant boundary, not a symbol decoration. An earlier `` grant authorized lexical placement that created nothing, and it does not authorize the persistent mutation this one does. A -continuation whose retained write table names a different identity for that -spelling is refused before generated execution instead of inheriting a broader -grant. Read-only admissions never selected the write table and remain -unaffected. - -That order is retained grant rather than presentation. A continuation is -compared against the entries position by position, so adding the deletion -spelling to the table refuses a continuation that was admitted under the former -two-entry one — before any generated effect, and deliberately: a run resumed -under a wider grant than it was admitted with is the thing the comparison -exists to refuse. A read-only admission never selected the write table and is -unaffected. +continuation whose retained rows name a different identity for that spelling is +refused before generated execution instead of inheriting a broader grant. +Read-only admissions never reached the writing spellings and remain unaffected. + +The retained rows are the grant rather than a presentation, and their order is +derived from the selection rather than from how a host assembled it: one row per +selected spelling, in canonical name order. A continuation is compared against +them row by row, so adding the deletion spelling refuses a continuation that was +admitted without it — before any generated effect, and deliberately: a run +resumed under a wider grant than it was admitted with is the thing the +comparison exists to refuse. A read-only admission never reached the writing +spellings and is unaffected. `` neither prompts nor approves: approval is authored control flow before the write-enabled element, and a branch not taken leaves no admission and no mutation. A refusal names the construct class and echoes no part of the candidate — in its diagnostic and in what it retains alike, because the decision is recorded -either way. That holds for a request the candidate malformed as much as for one -outside the admitted set: the component reports a bad URL, header or timeout by -quoting it, so a generated request is normalized behind a boundary that keeps -the class and drops the diagnostic. The host's own ceilings are not generated -text and are normalized before the admission, so a host that states a request it -cannot mean is told which one. Admission is the line: before it nothing of the -candidate is retained, and after it the exact source is retained on purpose. +either way. A request the candidate malformed is a different thing: whether a request is +well formed at all is the component's own question, asked where the request is +made — which is after the admission. Admission is the line: before it nothing of +the candidate is retained, and after it the exact source is retained on purpose, +so an ordinary component diagnostic naming part of the fragment discloses +nothing the journal does not already hold. A retained admission is a grant, and a resumed run is held to it. Durable replay matches an effect by its type and name, and what a description carries is stored rather than compared, so the normalized policy is retained in the admission's own result and a continuation checks it before one generated -component is invoked or one effect is performed. The check holds two kinds of -term differently, on purpose. The admission's Workspace roots and selected -root are an **as-of-admission retained basis** over a set the run's own -progress legitimately grows — every committed mutation retains another -immutable root and advances the authoritative current root — so the basis is -asked for by membership: every admission root and the admission's selected -root must still be retained, and the root the run now stands on must be a -retained one, while additional roots and an advanced current root change -nothing the admission was granted under. A run that no longer retains an -admission root has lost the history the grant was made over and is refused -before generated work. Every non-root term — the class selection, the -admitted spellings, and the normalized request ceilings — compares whole and -exactly, in canonical order. The retained policy holds one row per admitted +component is invoked or one effect is performed. Every term compares whole and +exactly, in canonical order: the class selection and the admitted spellings with +the identity each runs under. + +What is deliberately absent is everything live. The run's own progress is not a +term — every committed mutation retains another immutable root and advances the +authoritative current root, and a grant compared against a retained copy of that +frontier would refuse a run for exactly the work it was admitted to do. Nor is a +request ceiling: which destination an admitted read may reach is asked where the +request is performed, against the effective `Permissions` of that scope, so a +continuation held to a retained copy of it would refuse a run whose +configuration legitimately moved. The retained policy holds one row per admitted spelling: the name, the authored spelling, the absolute `https://executable.md/` identity that spelling runs under, whether canonical core supplies the body or the ordinary import chain answered for it, and which @@ -3671,11 +3686,9 @@ identities were an origin, a component key and a revision — is refused as unreadable history before the admission it would have restored performs anything, rather than being migrated or read in part. An updated implementation offered under the same identity is not a changed grant, because between -executions that is a newer build of the component the admission named. The run's -own ceiling table is keyed by the entry itself rather than by anything -describing one, so two entries that look alike each keep their own admitted -requests. Only the tables the selection reached take part, so changing a write -table a read-only admission never drew on changes nothing it was granted +executions that is a newer build of the component the admission named. Only the +classes the selection reached take part, so changing a writing spelling a +read-only admission never drew on changes nothing it was granted under. Deciding the fragment inside that durable effect is what makes this hold: a continuation restores what was admitted without consulting the current candidate at all, so @@ -3701,12 +3714,17 @@ comparison rejecting the run's own progress. An XMD-mediated HTTP read is how a network-denied Agent is given external information: the authored document performs the read with `` and the retained response reaches the prompt as data. That does not raise the Agent's own -ceiling. A generated fragment may name the pinned `Fetch` identity only when the -host admitted it, and then only for a request that normalizes — scheme, host, -path, method, headers and effective timeout alike — to one the host stated -exactly; anything else is refused before `API.Fetch` is reached. A repository -component that takes the name `Fetch` is not that identity and acquires no -admission from it. +ceiling. A generated fragment may name `Fetch` only when the host selected it, +and what an admitted read may actually reach is the live terminal's question: +a host that bounds its requests states that bound as a `Permissions` +restriction in the owner that performs the work, where it is asked of the +request as it would go out — after every `API.Fetch` transformation and +immediately before transport. So a dynamic destination the fragment's own data +expressions produce is an ordinary input, while a forbidden destination, method +or header reaches no transport at all. Selecting the name resolves whatever this +host answers for `Fetch`, so a repository component that takes it is what a +fragment would run — and one whose declaration offers no generated use of the +self-closing form refuses setup. ``'s two spellings are two grants. The component reads when it has no content and writes when it has some, so a read-only selection admits the @@ -3719,61 +3737,77 @@ Workflow-bundled Markdown component admission remains unbuilt. Directory registration is not among the gaps: a workflow Agent is given no directory to register. -#### Sealed evaluation entries +#### Captured generated implementations A paired Evaluate producer uses an execution-owned one-shot projection that bypasses public `content()` and `tryContent()` middleware. It renders under the narrowed syntax reference while retaining its own operational permission. Evaluate declares no `printErrors` boundary; its public refusal keeps throwing. -An entry states what is behind a name in one of exactly two ways, and neither carries a -function. A *capability* names the component canonical core supplies for it: capture -answers with core's own ordinary definition — the same ``, ``, -``, ``, `` or `` an author writes — held to the execution's -lifetime, with the component's own form dispatcher travelling beside it so canonical -selection enters the body the scan chose. The operation it performs is therefore the -ordinary one, through contextual `API.Files` and `API.Fetch`, under the provider the -host installed and the effective `Permissions` of the consuming scope; the host hands -the profile no operation of its own. - -A capability states what it binds wherever the ordinary component does because it *is* -the ordinary component, so an admitted `` is the value component the ordinary -one is — sharing its props, return contract, source rules and sanitized failure -sentence, refusing a missing `as` and an unusable pattern before the search, and -resolving its paths against the contextual working directory. - -One name runs one component, so a name admitted for capabilities owned by two different -components refuses at capture. An admitted `` is the one entry with a ceiling: -the element is classified against the entry's exact admitted requests, performs the -admitted one rather than its props, and that ceiling is installed as an ordinary -`Permissions` restriction for the invocation, so a contextual transformation that moves -the request off it is refused by the terminal rather than sent. - -A *component answer* names an implementation the ordinary import chain resolves. The -entry states the name and the spellings it admits, and nothing about what is behind -them: canonical execution resolves that name once — before the root import and before -any document code, through the complete ordinary `Component.importComponent` chain and a -private terminal that writes no record — and asks the publication owner about the exact -final answer in one call. That call answers with core's own copy of the definition a -provider published, taken at publication: the check and the thing kept are one result, -so nothing downstream reads the chain's object a second time and an answer whose members -read differently on each read cannot pass a check with one reading and be sealed with -another. The copy — not the answer — is sealed behind the same revocation. +A selection states a name and the spellings it offers, and nothing about what is behind +them. Canonical execution resolves that name once — after initialization and the +captured installation callbacks, before public execution policy, through the complete +ordinary `Component.importComponent` chain and a private terminal that writes no record +— and asks the publication owner about the exact final answer in one call. That call +answers with core's own copy of the definition that was published, taken at publication: +the check and the thing kept are one result, so nothing downstream reads the chain's +object a second time and an answer whose members read differently on each read cannot +pass a check with one reading and be captured with another. The copy — not the answer — +is held behind the execution's revocation. + +There are exactly two publications a selected name may be answered by, and both are made +where the answer was produced rather than where it came back. A provider publishes +through the request it was handed. Canonical resolution publishes through core's own +installation, from the innermost position, on the exact object materialization produced +— so core's defaults, its fixed filesystem components, the protected tier, a +registration a package made and a repository component are all legitimate answers for a +selected name, and a host does not install a provider to be given what it already +resolves to. A handler that replaces the answer returns something nobody published; one +that edits the canonical answer in place is compared against the copy taken at +publication; and an answer with no implementation to invoke — a Markdown definition, a +structural one — is refused. All three refuse before an execution holds a profile at +all. + +That is also what makes a replaceable name's selection honest. Selecting ``, +`` or `` resolves what this host *actually* answers for it, so a +registration, repository file, declaration or separately loaded definition that +legitimately shadows one is what the fragment would run — and one whose declaration +offers no generated use of the selected spelling refuses setup, even when the eventual +fragment would never write that name, rather than silently reaching core's default. +`File`, `File.Delete` and `Dir` are canonically protected, so nothing can answer for +them but core. What an admitted spelling then runs as comes from that definition's own declaration: an absolute `https://executable.md/` identity and one of the two effects, per authored -spelling, read where the definition is published and retained with it. So the table says -which names and spellings a fragment may write, and the owner says what writing them -runs — an entry whose spelling its owner offers no generated use of, or offers as the -other effect, refuses at capture rather than acquiring a grant from the table that named -it. The installation origin a publication carries is provenance a reader and a -documentation symbol are shown, and decides nothing. - -One name resolves to one implementation, however many spellings and tables hold it: two -entries under one name are the two spellings of one component and share one lookup and -one sealed definition, while two definitions under one name, or a name held as both a -capability and a component answer, refuses at capture rather than letting assembly order -decide. +spelling, read where the definition is published and retained with it. So the selection +says which names and spellings a fragment may write, and the owner says what writing +them runs and which class it reaches — a spelling whose owner offers no generated use of +it refuses at capture rather than acquiring a grant from the selection that named it. +The installation origin a publication carries is provenance a reader and a documentation +symbol are shown, and decides nothing. + +An admitted name is the component an author writes, so an admitted `` is the +value component the ordinary one is — sharing its props, return contract, source rules +and sanitized failure sentence, refusing a missing `as` and an unusable pattern before +the search, and resolving its paths against the contextual working directory. The +operation it performs is the ordinary one, through contextual `API.Files` and +`API.Fetch`, under the provider the host installed and the effective `Permissions` of +the consuming scope; the selection carries no operation of its own. + +One name resolves to one implementation, however many spellings and classes hold it: the +two spellings of one component share one lookup and one captured definition, and which +class each lands in is the owner's answer rather than the selection's. + +What a captured implementation adds around that body is two things and no more. The +execution's lifetime, so a definition somebody kept refuses rather than running against +a run that is over. And the classification a host driving a loop needs: an ordinary +refusal from an admitted component — an unreadable path, an unusable pattern, a refused +deletion or directory — is marked as a generated-request refusal carrying a safe reason +built from the effect the spelling reaches and the authored path, never from anything +the fragment produced. Everything else is left unmarked, which is the safe default: an +absent provider, a broken provider contract, a revoked execution, a cancellation and a +durable failure are not the request being refused, and calling any of them one would +invite another turn at work that cannot succeed. Provider installation and the right to answer one occurrence are separate: installation receives a registrar that installs import middleware, and every invocation of that @@ -3906,13 +3940,13 @@ paths through read-only generated XMD. The loop belongs to packaged ``: Plan remains inert source throughout classification, review and approval. Reading information is not execution of the program being authored. -The command hosts supply the same ordinary evaluation profile before their -executions begin. Its always-on composition table admits core's exact ``. -Its read table admits canonical self-closing `` and ``, and exact -canonical protected self-closing ``. Its write table remains paired -File and self-closing File.Delete. The ordinary run's -write-enabled evaluations retain their existing behavior. Plan information -requests select only read: +The command hosts offer the same ordinary selection in the scope their +executions run in. The always-on composition table admits core's exact +``. The selection is `File` in both spellings, self-closing +`File.Delete`, self-closing `Glob`, self-closing `Syntax` and paired `Elicit`; +which class each reaches is the owner's answer, so the reading spellings are +what a `read` selection gets. The ordinary run's write-enabled evaluations +retain their existing behavior. Plan information requests select only read: ```mdx @@ -3967,14 +4001,14 @@ minted, because each is handed a site canonical execution built for it. File, File.Delete and Dir are ordinary components whose bodies reach the filesystem through contextual `API.Files`, so each is one definition built once per loaded copy of -core and answered with as it stands — which is also the definition the -generated-XMD write table pins, so an authored element and an admitted -generated one invoke one implementation. Owning those three names fixes no +core and answered with as it stands — which is also the definition a +generated selection of those names resolves to, so an authored element and an +admitted generated one invoke one implementation. Owning those three names fixes no provider: Files middleware still answers, transforms, delegates and refuses every operation they perform. -The host profile admits that exact implementation and self-closing form to -generated XMD. The authored `allow` selects an already-installed class as a whole; it +The host selection names those components, and canonical resolution answers with +that exact implementation. The authored `allow` selects an already-installed class as a whole; it neither selects individual entries nor grants anything absent from that class. Plan's read selection never selects write. A generated request cannot widen it by naming Evaluate, writing another `allow`, or requesting documentation. @@ -4114,15 +4148,15 @@ untrusted-data boundary, not behavior special to Json. Complete-fragment preflight rejects an unauthorized effectful name or form and an unsupported executable construct before any read begins. File writes and deletion, network, credentials APIs, tools, processes, Agents, elicitation and -services are not in the selected read table. +services are not among the selected reading spellings. A later runtime read failure can follow an earlier successful read, but the failed request supplies no partial findings to a later turn. #### Recovery composes around Evaluate Evaluate keeps its existing public props and its existing throwing behavior. It -gains no `limits` prop, and the evaluation profile gains no deadline and no -output budget. A profile states permission and nothing else. +gains no `limits` prop, and the generated selection gains no deadline and no +output budget. A selection states names and forms and nothing else. Information requests add **no local deadline and no rendered-output size limit**. Ordinary component behavior and the host's own limits are what bound the work, @@ -5557,9 +5591,9 @@ been. **Protection is about the answer, not about power.** Canonical expansion hands a protected body its lexical syntax reference and the internal operations its -site owns. `` receives the captured evaluation profile, its paired -content projection and an operation that derives a child protected route for -exact admitted definitions. None is published through a context, component API, +site owns. `` receives what the execution captured from the host's generated +selection, its paired content projection and an operation that derives a child +protected route for exact admitted definitions. None is published through a context, component API, import answer or public host input. Protected lookup uses exact function identity. The execution's route projects @@ -6798,9 +6832,9 @@ Status is measured against main. | historical authored source | retains an authored durable operation's normalized `SourcePosition` beside its identity, and history parses it or refuses the entry [Invariant](#read-only-lifecycle-inspection); [Interface and evidence](specs/workflow-workspace-spec.md#42-history). | built on the #367 stack | | history fork | creates a new run from one compatible checkpoint and retained Workspace root, under a new immutable definition and normalized props [Invariant](#retained-effects-and-history-forks); [Interface and evidence](specs/workflow-workspace-spec.md#11-history-forks). | built on the #368 stack, Deno provider only | | workflow Agent session | Uses inert placement and authenticated first-turn mapping under the run-owned no-native-tool profile. [Session mapping](#agent-permissions-and-generated-xmd). | built on the #302 stack, with the explicit ACP-only selection from #561; the portable proof that an adapter honours an empty tool set is tracked by #496 and does not widen the ceiling | -| generated-XMD admission | Preflights and retains one generated fragment under exact selected identities/forms and request ceilings. [Admission and continuation](#agent-permissions-and-generated-xmd). | built on the #369 stack, continuation basis amended by #589; core owns the mechanics and the workflow policy wrapper is internal | -| `` | Runs untrusted fragment text or paired-produced text under one captured host ceiling; whole-fragment admission, local data expressions and ordinary rendered output remain distinct from provider permission. [Admission and sealed entries](#agent-permissions-and-generated-xmd). | built on the #302 stack, extended by #369 | -| generated mutation proposals | Admits filesystem change proposals through the selected generated write table and ordinary component transactions. [Invariant](#agent-permissions-and-generated-xmd); [Interface and evidence](specs/workflow-workspace-spec.md#mutation-proposal-admission). | Built on #369/#567, with persistent directory creation added by #643; legacy Dir grants refuse, and Git/process/credential/external-write classes remain excluded. | +| generated-XMD admission | Preflights and retains one generated fragment under the exact selected identities and forms. [Admission and continuation](#agent-permissions-and-generated-xmd). | built on the #369 stack, continuation basis amended by #589, and the host's contribution narrowed to `Component.generated` by this stack; core owns the mechanics and no host wrapper remains | +| `` | Runs untrusted fragment text or paired-produced text under the host's captured generated selection; whole-fragment admission, local data expressions and ordinary rendered output remain distinct from provider permission. [Admission and captured implementations](#agent-permissions-and-generated-xmd). | built on the #302 stack, extended by #369, and narrowed to a name-and-form selection by this stack | +| generated mutation proposals | Admits filesystem change proposals through the host's generated selection and ordinary component transactions. [Invariant](#agent-permissions-and-generated-xmd); [Interface and evidence](specs/workflow-workspace-spec.md#mutation-proposal-admission). | Built on #369/#567, with persistent directory creation added by #643; legacy Dir grants refuse, and Git/process/credential/external-write classes remain excluded. | | Deno-local DOFS provider | owns one authoritative SQLite/DOFS connection per run path, captures arbitrary canonical retained roots, privately restores them, and atomically coordinates one Workspace mutation with its filtered Yield [Invariant](#local-workspace-topology). | built on the #365 stack; public document filesystem effects and the CLI lifecycle route to it on the #366 stack | | scoped Worker Shell | executes `just-bash` through the Workspace adapter inside a Deno Worker [Interface and evidence](specs/workflow-workspace-spec.md#104-worker-shell). | containment and effect-transaction POCs complete (#351, #357); production integration unbuilt | | `` | retry a region until it completes [Invariant](#1-middleware-wraps-the-work). | defined, unbuilt | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8b90a99de..9c8ce4a3d 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -134,7 +134,7 @@ import { scanPlanArgs, } from "./plan-args.ts"; import type { PlanScan } from "./plan-args.ts"; -import { ordinaryEvaluationProfile, statesEvaluation } from "./evaluation-profile.ts"; +import { useOrdinaryGeneratedSelection } from "./evaluation-profile.ts"; import { runPlan } from "./plan.ts"; import { runUpgrade } from "./upgrade.ts"; import type { UpgradeAssembly } from "./upgrade.ts"; @@ -1406,6 +1406,12 @@ function* runDocument( planDeclaration, }); + // What a generated fragment may write here, offered in the scope this + // execution runs in. It delegates first, so a host that attached its own + // selection — a workflow attachment states its own — keeps it, and this + // command's ordinary selection answers only where nothing else did. + yield* useOrdinaryGeneratedSelection(); + // One authoritative execution, and only one. What a host attaches travels as // values canonical core captures before anything else exists — never as a // second call, and never as middleware that could be reordered around this @@ -1440,13 +1446,6 @@ function* runDocument( // and does not gain `` at its root — but the production run child // it can launch is the run profile, and gets it below. ...(mode.testing ? {} : { declarations: [plan] }), - // The ceiling a generated fragment runs under, stated only where the - // host that attached this execution stated none: a workflow attachment - // states its own Workspace-bound profile, and one execution offers one - // maximum permission. - ...(statesEvaluation(mode.installations) - ? {} - : { evaluation: ordinaryEvaluationProfile() }), }, // The declarations a nested execution may configure a child with, named // by the exact definitions this command installed. Recognizing one is diff --git a/packages/cli/src/evaluation-profile.ts b/packages/cli/src/evaluation-profile.ts index cb33bc8ec..dee80d5ff 100644 --- a/packages/cli/src/evaluation-profile.ts +++ b/packages/cli/src/evaluation-profile.ts @@ -1,87 +1,108 @@ /** - * What `xmd run` lets a generated fragment do. + * What `xmd run` offers a generated fragment, as ordinary Component middleware. * * `` is public and canonical core owns what it means, so this file - * does not decide the component — it decides the *ceiling*, which is the only + * does not decide the component — it decides the *selection*, which is the only * part a host owns. It is stated here, at the assembly this command already - * does, before any document exists to ask for it. + * does, in the scope that will execute the document. * - * ## The two tables + * ## What is selected * - * `read` is core's self-closing ``, its self-closing `` and - * canonical ``. `write` is core's paired `…`, its - * self-closing `` and paired canonical ``: a fragment - * that may change something may also ask the person first, which is what makes - * "preview this, then confirm it" a program an Agent can write. A `read` - * selection admits no question — it promises nobody will be interrupted. + * Names and authored forms, and nothing else: * - * A fragment run by `xmd run` therefore reaches the caller's own filesystem - * through the Files provider this command installed, and the person through the - * Elicitation provider the run already has. It reaches nothing else at all: no - * network read, no process, no repository, no Git, no credential and no agent. + * ```ts + * { + * File: ["self-closing", "paired"], + * "File.Delete": ["self-closing"], + * Glob: ["self-closing"], + * Syntax: ["self-closing"], + * Elicit: ["paired"], + * } + * ``` * - * ## It states no filesystem of its own + * Core resolves each of those names once, before public execution policy, and + * reads the identity, the effect and the contract of every selected spelling + * off the declaration that owns the implementation. So reading and writing stay + * separate spellings of one name — `allow={["read"]}` admits `` and not + * `…` — without this file saying anything about what either does. + * + * `` is selected paired: a fragment that may change something may also + * ask the person first, which is what makes "preview this, then confirm it" a + * program an Agent can write. Its owner offers the paired form as a write, so a + * `read` selection admits no question and promises nobody will be interrupted. * - * An admitted name runs canonical core's own component, which performs its - * operation through contextual `API.Files` — the provider this command's - * entrypoint installed, and the same one every authored `` in the - * document crosses. So a fragment's read is observed, transformed, delegated - * and refused by exactly the middleware an author's read is, and the effective - * `Permissions` of the scope it runs in decide it. This file hands the profile - * no operation at all, because there is no second filesystem for it to hand. + * `Dir` and `Fetch` are absent rather than present-and-bounded. An ordinary run + * creates no directory and reads no network on a document's behalf, and + * selecting a name a fragment could never usefully call would tell an agent it + * had something it does not. * - * Reading and writing stay separate spellings of one name. A selection of - * `read` admits `` and not `…`, so a fragment that asks to - * search and read cannot also write, and `` describing a component - * says only what the vocabulary holds — a name it renders is not a name the - * selection admits. + * ## It states no filesystem of its own * - * `` is absent rather than present-and-bounded. An unbounded network - * read is a decision this command does not make on a document's behalf, and - * admitting the name with an empty ceiling would be a different thing from not - * admitting it — the fragment would name a component it can never successfully - * call. + * A selected name runs the implementation canonical resolution answered with, + * which performs its operation through contextual `API.Files` — the provider + * this command's entrypoint installed, and the same one every authored `` + * in the document crosses. So a fragment's read is observed, transformed, + * delegated and refused by exactly the middleware an author's read is, and the + * effective `Permissions` of the scope it runs in decide it. * - * ## No Workspace + * ## Delegate first, default only on absence * - * An ordinary run evaluates against no Workspace. Its admitted effects address - * the Files provider its own execution installed, and there is no immutable - * root history for a continuation to be held to — which is a different - * statement from evaluating against an empty one, and the profile says so by - * omitting the access rather than answering it with nothing. + * This is ordinary property middleware: it asks what the scope around it + * already offers and supplies the selection above only when that answer is + * `undefined`. A host that stated its own — a workflow attachment, a test + * harness, a plugin — keeps it, and a defined answer that is empty or malformed + * is preserved for core to refuse rather than quietly replaced with this one. + * Two handlers compose normally; there is no contributor count, no automatic + * union and no installation-array inspection. * - * It accepts `text` and refuses `source`: `xmd run` never shipped the earlier - * spelling, so there is no document written against it to keep working. + * `useGeneratedFallback()` is that rule, and every ordinary host that supplies a + * default goes through it — this command, each REPL entry, an isolated run + * child. A host that answered instead would be the one place a plugin's or a + * captured installation callback's selection could not be read, which is the + * same as not composing at all. */ -import { - elicitWriteEntry, - fileDeleteEntry, - fileReadEntry, - fileWriteEntry, - globReadEntry, - syntaxReadEntry, -} from "@executablemd/core/host"; -import type { ExecutionInstallation, FragmentEvaluationInput } from "@executablemd/core/host"; +import { Component } from "@executablemd/core/api"; +import type { GeneratedSelection } from "@executablemd/core/api"; +import type { Operation } from "effection"; -/** The ceiling `xmd run` and its run children state. */ -export function ordinaryEvaluationProfile(): FragmentEvaluationInput { +/** The names and forms `xmd run` and its run children offer. */ +export function ordinaryGeneratedSelection(): GeneratedSelection { return { - read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], - write: [fileWriteEntry(), fileDeleteEntry(), elicitWriteEntry()], + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + Glob: ["self-closing"], + Syntax: ["self-closing"], + Elicit: ["paired"], }; } /** - * Whether the host that attached this execution already stated a ceiling. + * Offer this selection for this scope, unless something nearer already offers + * one. * - * One execution offers one maximum permission, so a workflow attachment — which - * states its own Workspace-bound profile — is not also given the run profile's. - * Asked about the installations rather than resolved by order, because - * "whichever came last wins" is exactly how permission stops being auditable. + * Installed where the document will execute. Installing it in a scope that ends + * before the execution would leave the middleware chain core reads composed of + * handlers that are no longer there. + * + * The composition rule lives here rather than at each caller, so a host that + * supplies a default of its own supplies it the same way. */ -export function statesEvaluation( - installations: readonly ExecutionInstallation[] | undefined, -): boolean { - return (installations ?? []).some((installation) => installation.evaluation !== undefined); +export function useGeneratedFallback(selection: GeneratedSelection): Operation { + return Component.around({ + // A value member, so the chain is synchronous: `next()` answers with what + // the scope around this already offers, and only absence defaults. An empty + // or malformed selection is a host statement core validates and refuses; + // replacing it here would answer a host's mistake with this command's + // permission. + generated: (_args, next) => { + const stated = next(); + return stated === undefined ? selection : stated; + }, + }); +} + +/** Offer the ordinary selection, unless something nearer already offers one. */ +export function useOrdinaryGeneratedSelection(): Operation { + return useGeneratedFallback(ordinaryGeneratedSelection()); } diff --git a/packages/cli/src/plan-writer-profile.ts b/packages/cli/src/plan-writer-profile.ts index 0694a0d55..0596bb480 100644 --- a/packages/cli/src/plan-writer-profile.ts +++ b/packages/cli/src/plan-writer-profile.ts @@ -55,7 +55,7 @@ import { FormOpener } from "@executablemd/web"; import { hostAcpDependencies } from "./agent-stack.ts"; import type { PlanWriterStack } from "./agent-stack.ts"; -import { ordinaryEvaluationProfile } from "./evaluation-profile.ts"; +import { useOrdinaryGeneratedSelection } from "./evaluation-profile.ts"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts"; /** @@ -450,6 +450,17 @@ export function* runPlanCommandDocument(profile: PlanWriterProfile): Operation`: a component that + // offered its own would be choosing its ceiling instead of being given + // one. The target-run description `installSyntax()` bound above stays + // separate — describing a component neither imports it nor grants its + // effects. + yield* useOrdinaryGeneratedSelection(); + // The command root is a thin adapter: it projects the request into // `` and returns what comes back. Everything that used to be // installed around it — the directory, the provider, the Elicitation, the @@ -478,13 +489,6 @@ export function* runPlanCommandDocument(profile: PlanWriterProfile): Operation`: - // a component that installed its own would be choosing its - // ceiling instead of being given one. - evaluation: ordinaryEvaluationProfile(), }, ], ); diff --git a/packages/cli/src/repl-profile.ts b/packages/cli/src/repl-profile.ts index 532edfc7e..a4156294f 100644 --- a/packages/cli/src/repl-profile.ts +++ b/packages/cli/src/repl-profile.ts @@ -16,13 +16,14 @@ import type { AcpxProviderDependencies } from "@executablemd/acp"; import { agentIdentityComponents } from "@executablemd/core"; import type { PermissionMode } from "@executablemd/core"; +import type { GeneratedSelection } from "@executablemd/core/api"; import type { ExecutionInstallation } from "@executablemd/core/host"; import { useScope } from "effection"; import type { Operation } from "effection"; import { installAgentProviderStack } from "./agent-stack.ts"; import type { AgentStack } from "./agent-stack.ts"; -import { ordinaryEvaluationProfile } from "./evaluation-profile.ts"; +import { ordinaryGeneratedSelection } from "./evaluation-profile.ts"; import { planComponentDeclaration } from "./plan-component.ts"; import { planAgentContext } from "./plan-writer-profile.ts"; import type { CommandPlugins } from "./plugin-host.ts"; @@ -60,10 +61,20 @@ export interface ReplExecutionProfile { * What this command declares to every execution it opens. * * In installation order: the selected Plugins as they were assembled, then - * this command's own vocabulary — the Agent identity components, the packaged - * `` Component, and the ceiling a generated fragment runs under. + * this command's own vocabulary — the Agent identity components and the + * packaged `` Component. */ readonly installations: readonly ExecutionInstallation[]; + /** + * What a generated fragment may write in every entry this command opens. + * + * Data, like everything else here: the names and authored forms this command + * offers, which the session installs as ordinary Component middleware in each + * entry's own execution scope. A profile-construction scope ends before the + * first entry runs, so a selection installed there would be a chain nothing + * reads. + */ + readonly generated: GeneratedSelection; /** How this REPL answers an Agent permission request. */ readonly permissionMode: PermissionMode; } @@ -115,9 +126,9 @@ export function* assembleReplProfile( Object.freeze({ components: agentIdentityComponents(), declarations: Object.freeze([plan]), - evaluation: ordinaryEvaluationProfile(), }), ]), + generated: ordinaryGeneratedSelection(), permissionMode: stack.permissionMode, }); } diff --git a/packages/cli/src/repl/expansion.ts b/packages/cli/src/repl/expansion.ts index c44d4944a..08ff6cbf8 100644 --- a/packages/cli/src/repl/expansion.ts +++ b/packages/cli/src/repl/expansion.ts @@ -76,11 +76,14 @@ export interface BoundaryPartition { /** * `Component`, partitioned. * - * `env`, `evalScope`, `persistent` and `registry` are values rather than steps — - * there is no continuation at them to hold. `hasCapture`, `hasContent` and - * `hasBinding` answer a question about an invocation the walk has already - * reached; holding one would stop the same walk the gate before it already - * stops, one question later. + * `env`, `evalScope`, `persistent`, `registry` and `generated` are values rather + * than steps — there is no continuation at them to hold. `generated` is also + * read before any expansion exists: canonical execution asks for the host's + * generated selection once, before public execution policy, so there is no walk + * for a hold to be standing in. `hasCapture`, `hasContent` and `hasBinding` + * answer a question about an invocation the walk has already reached; holding + * one would stop the same walk the gate before it already stops, one question + * later. * * `expand` surrounds an element's whole expansion, which makes it a step in * the walk that reached it and not a walk of its own. Treating it as one would @@ -102,7 +105,16 @@ export const COMPONENT_BOUNDARIES: BoundaryPartition = { "raise", "handleFailure", ], - unheld: ["env", "evalScope", "persistent", "registry", "hasCapture", "hasContent", "hasBinding"], + unheld: [ + "env", + "evalScope", + "generated", + "persistent", + "registry", + "hasCapture", + "hasContent", + "hasBinding", + ], }; /** diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index 0798c657e..9e46e520c 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -206,6 +206,7 @@ export function* runReplProgram(options: ReplProgramOptions): Operation> { - const { execution, includes, installations = [], selection, permissionMode } = options; + const { execution, includes, installations = [], generated, selection, permissionMode } = options; const stream = execution.stream; const events = yield* stream.readAll(); @@ -933,6 +947,18 @@ function* start( // provider work come down first, then this attachment discards whatever it // never transferred, and only then is the session's entry slot freed. const installation = yield* agent.owning(key); + // This entry's own selection, offered in the scope this execution runs + // in. Every entry installs it again: core reads the final contextual + // answer once per execution, and a chain assembled in a scope that has + // already ended answers nothing. + // + // Offered as the ordinary fallback rather than as the answer. The + // session's value is the command's; a plugin or a captured installation + // callback that states its own is nearer, and answering here is what + // would hide it. + if (generated !== undefined) { + yield* useGeneratedFallback(generated); + } const started = yield* executeInstalled( { ...inlineSource(source), diff --git a/packages/cli/src/testing-host.ts b/packages/cli/src/testing-host.ts index 8a771ce8c..d34820269 100644 --- a/packages/cli/src/testing-host.ts +++ b/packages/cli/src/testing-host.ts @@ -61,7 +61,7 @@ import { installDocumentComponents } from "./cli.ts"; import { installPlugins } from "./plugin-host.ts"; import { nestedRunProfile } from "./run-profile.ts"; import type { Plugin } from "@executablemd/core/api"; -import { ordinaryEvaluationProfile } from "./evaluation-profile.ts"; +import { useOrdinaryGeneratedSelection } from "./evaluation-profile.ts"; import type { HostServiceInstaller } from "./cli.ts"; import type { RepositoryInstaller } from "./run-repositories.ts"; @@ -342,11 +342,6 @@ function* runProfileChild( // this child settled above, rather than taken from a declaration the // entrypoint built before this child's configuration had been read. installations.push({ - // The run profile's own evaluation ceiling. A `host="run"` child is an - // ordinary run whatever command is hosting it, so a child of `xmd test` — - // whose own document is a different profile — still evaluates a generated - // fragment under exactly what `xmd run` states. - evaluation: ordinaryEvaluationProfile(), declarations: [ yield* settings.planDeclaration({ context, @@ -387,6 +382,13 @@ function* runProfileChild( // And Repository selection the same way, from the same installer the command // used — a fresh instance for this child alone. yield* settings.installRepositories(); + // The run profile's own generated selection, offered in this child's own + // scope. A `host="run"` child is an ordinary run whatever command is hosting + // it, so a child of `xmd test` — whose own document is a different profile — + // still evaluates a generated fragment under exactly what `xmd run` offers. + // Installed here rather than inherited: the child's scope is isolated from + // the command that started it, so a parent's middleware reaches none of this. + yield* useOrdinaryGeneratedSelection(); const execution = yield* executeInstalled( { diff --git a/packages/cli/src/workflow.ts b/packages/cli/src/workflow.ts index 220057763..c25390892 100644 --- a/packages/cli/src/workflow.ts +++ b/packages/cli/src/workflow.ts @@ -70,7 +70,7 @@ import { field, object, cli } from "configliere"; import { z } from "zod"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; import { retainedSource, validateProps } from "@executablemd/core"; -import { gitDirectoryEntry } from "@executablemd/git"; +import { gitDirectorySelection } from "@executablemd/git"; import type { PropsSchema } from "@executablemd/core"; import type { RootDocumentSource } from "@executablemd/core"; import { @@ -383,6 +383,19 @@ export interface WorkflowExecution { around(operation: Operation): Operation; } +/** + * Install one host setup operation, then run the document in that same scope. + * + * Ordinary mechanics rather than a capture protocol: what a selection answers + * has to be composed in a scope that is still open when canonical execution + * reads it, and the attachment chain is built from operations rather than from + * scopes a caller can reach into. + */ +function* offering(operation: Operation, setup: () => Operation): Operation { + yield* setup(); + return yield* operation; +} + /** How one `start` or `resume` ended. */ export interface WorkflowOutcome { readonly exitCode: number; @@ -1040,7 +1053,7 @@ export function runWorkflow( // reason: it is closed over this run's storage, and a run that opens no // workflow storage must not load the adapter that reaches `node:sqlite` — // which Bun does not have at all. - const { createSuspensionController, evaluationProfile } = yield* until( + const { createSuspensionController, useWorkflowGenerated } = yield* until( import("@executablemd/workflow/deno"), ); const suspension = createSuspensionController({ database }); @@ -1071,21 +1084,6 @@ export function runWorkflow( ...(source.value.components.length === 0 ? [] : [workflowBundleInstallation(source.value.components)]), - // The ceiling a generated fragment runs under. `` itself is - // canonical core's, so what this run states is the profile and nothing - // about the component. Stated where the Workspace is attached — a - // completed replay restores its retained output and expands nothing, so - // it needs no ceiling of its own. - // ``'s entry comes from the Git Plugin, which decides that a - // workflow run has one at all; the identity it is admitted under is - // core's, from the declaration that owns the component. - ...(completed || replay - ? [] - : [ - { - evaluation: yield* evaluationProfile(database, { directory: gitDirectoryEntry() }), - }, - ]), ], around(operation: Operation): Operation { // A completed run replays its retained output and result. Attaching a @@ -1096,9 +1094,23 @@ export function runWorkflow( // The suspension controller owns the scope *inside* the attachment, so // halting a suspended execution tears the Workspace down with it and // nothing survives the settlement. + // + // The generated selection is offered inside the attachment, in the + // scope that executes the document: a completed replay restores its + // retained output and expands nothing, so it is offered nothing. + // ``'s spelling comes from the Git Plugin, which decides that a + // workflow run offers the name at all; the identity it runs under is + // core's, from the declaration that owns the component. return completed || replay ? suspension.own(operation) - : host.attach(database, suspension.own(operation)); + : host.attach( + database, + suspension.own( + offering(operation, () => + useWorkflowGenerated({ components: gitDirectorySelection() }), + ), + ), + ); }, }; diff --git a/packages/cli/tests/evaluate-component.test.ts b/packages/cli/tests/evaluate-component.test.ts index dbdfeefcf..3bf7fb9ec 100644 --- a/packages/cli/tests/evaluate-component.test.ts +++ b/packages/cli/tests/evaluate-component.test.ts @@ -16,12 +16,17 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { runCli } from "@executablemd/test-support/launch"; import { ensureDir, readTextFile, rm, writeTextFile } from "@effectionx/fs"; -import { ensure, scoped } from "effection"; +import { ensure, scoped, useScope } from "effection"; import type { Operation } from "effection"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { CORE_COMPONENT_NAMES } from "@executablemd/core"; +import { Component } from "@executablemd/core/api"; +import { + ordinaryGeneratedSelection, + useOrdinaryGeneratedSelection, +} from "../src/evaluation-profile.ts"; import { syntaxSymbols } from "../src/syntax.ts"; const NOTE = "the retained note\n"; @@ -110,6 +115,72 @@ describe("Tier FE — the ordinary run profile", () => { }); }); +describe("Tier FE — the ordinary selection is middleware, and it delegates", () => { + /** + * The exact map `xmd run` offers. + * + * Written out rather than derived, because what these rows are about is the + * value: a selection assembled by calling the thing under test would prove + * only that it equals itself. + */ + it("FE24: the ordinary selection is exactly File, File.Delete, Glob, Syntax and Elicit", function* () { + expect(ordinaryGeneratedSelection()).toEqual({ + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + Glob: ["self-closing"], + Syntax: ["self-closing"], + Elicit: ["paired"], + }); + // `Dir` and `Fetch` are absent rather than offered with nothing a fragment + // could usefully do with them. + expect(Object.keys(ordinaryGeneratedSelection())).not.toContain("Dir"); + expect(Object.keys(ordinaryGeneratedSelection())).not.toContain("Fetch"); + yield* useScope(); + }); + + it("FE24: a host's own defined selection is preserved, and two handlers compose", function* () { + // Delegate first, default only on absence. A host that stated its own + // keeps it, and the ordinary selection answers only where nothing did. + // This command's handler first, so it is the outer one and the host's + // answer is what its `next()` reaches — which is the composition a workflow + // attachment makes, where the attachment's scope encloses the run's. + const stated = yield* scoped(function* () { + yield* useOrdinaryGeneratedSelection(); + yield* Component.around({ generated: () => ({ File: ["self-closing"] }) }); + return yield* Component.operations.generated; + }); + expect(stated).toEqual({ File: ["self-closing"] }); + + const defaulted = yield* scoped(function* () { + yield* useOrdinaryGeneratedSelection(); + return yield* Component.operations.generated; + }); + expect(defaulted).toEqual(ordinaryGeneratedSelection()); + + // Two of this command's own handlers compose rather than refusing: the + // nearer one answers, and there is no contributor count to object to. + const composed = yield* scoped(function* () { + yield* useOrdinaryGeneratedSelection(); + yield* useOrdinaryGeneratedSelection(); + return yield* Component.operations.generated; + }); + expect(composed).toEqual(ordinaryGeneratedSelection()); + }); + + it("FE24: a defined empty selection is preserved for core to refuse", function* () { + // Only absence defaults. An empty selection is a host statement core + // validates and refuses; replacing it here would answer a host's mistake + // with this command's permission, and the run would quietly evaluate under + // a vocabulary nobody chose. + const empty = yield* scoped(function* () { + yield* useOrdinaryGeneratedSelection(); + yield* Component.around({ generated: () => ({}) }); + return yield* Component.operations.generated; + }); + expect(empty).toEqual({}); + }); +}); + describe("Tier FE — the one spelling this host accepts", () => { it("FE19: every profile refuses `source`, which is no longer a spelling", function* () { yield* useWorkspace( diff --git a/packages/cli/tests/evaluate-workflow.test.ts b/packages/cli/tests/evaluate-workflow.test.ts index 1bbb35d29..07db73d25 100644 --- a/packages/cli/tests/evaluate-workflow.test.ts +++ b/packages/cli/tests/evaluate-workflow.test.ts @@ -18,15 +18,19 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped } from "effection"; +import { scoped, useScope } from "effection"; import type { Operation } from "effection"; import { collect, inlineSource } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { Json } from "@executablemd/durable-streams"; import { API, useHostFiles } from "@executablemd/runtime"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; -import { evaluationProfile, withWorkflowWorkspace } from "@executablemd/workflow/deno"; -import { gitDirectoryEntry } from "@executablemd/git"; +import { + useWorkflowGenerated, + withWorkflowWorkspace, + workflowGeneratedSelection, +} from "@executablemd/workflow/deno"; +import { gitDirectorySelection } from "@executablemd/git"; import { createRun, useStorageRoot, withStorage } from "./support/workflow-run.ts"; /** @@ -56,15 +60,11 @@ function runDocument( const output = yield* withWorkflowWorkspace( database, scoped(function* () { + // The bundled XMD workflow selection: Git supplies ``. Offered + // inside the attachment, in the scope that executes the document. + yield* useWorkflowGenerated({ components: gitDirectorySelection() }); return yield* collect( - yield* executeInstalled({ ...inlineSource(source), stream: database.journal }, [ - // The bundled XMD workflow profile: Git supplies ``. - { - evaluation: yield* evaluationProfile(database, { - directory: gitDirectoryEntry(), - }), - }, - ]), + yield* executeInstalled({ ...inlineSource(source), stream: database.journal }, []), ); }), ); @@ -78,45 +78,35 @@ function runDocument( const NOTE = "the retained note"; describe("Tier FE — the workflow host's profile", () => { - it("FE22: the directory capability is the host's, and Git is what supplies it", function* () { - // `` belongs to `@executablemd/git`. Workflow states no entry for it - // at all now, so a generic Workflow host composing this profile has no - // directory capability — and the XMD workflow profile has one because Git - // hands it over, not because Workflow kept a copy. - const root = yield* useStorageRoot(); - yield* withStorage(root, function* () { - const database = yield* createRun(); - - const generic = yield* evaluationProfile(database); - const names = (generic.write ?? []).map((entry) => entry.name); - // Non-empty, so "no Dir" is an absence among entries rather than an - // empty table that would satisfy the claim by containing nothing. - expect(names.length > 0).toBe(true); - expect(names).not.toContain("Dir"); - - // And the position is the contract: a retained continuation compares - // this table position by position, so the supplied entry sits exactly - // between the file write and the file delete. - const bundled = yield* evaluationProfile(database, { directory: gitDirectoryEntry() }); - expect((bundled.write ?? []).map((entry) => entry.name)).toEqual([ - "File", - "Dir", - "File.Delete", - ]); - }); + it("FE22: `` is the host's choice, and Git is what supplies it", function* () { + // `` belongs to `@executablemd/git`. Workflow offers the name not at + // all now, so a generic Workflow host composing this selection has no + // directory spelling — and the XMD workflow run has one because Git hands + // it over, not because Workflow kept a copy. + const generic = workflowGeneratedSelection(); + // Non-empty, so "no Dir" is an absence among selected names rather than an + // empty selection that would satisfy the claim by containing nothing. + expect(Object.keys(generic).length > 0).toBe(true); + expect(Object.keys(generic)).not.toContain("Dir"); + // Nor any network read: `` joins the selection only where this host + // bounds what a request may reach. + expect(Object.keys(generic)).not.toContain("Fetch"); + + const bundled = workflowGeneratedSelection({ components: gitDirectorySelection() }); + expect(Object.keys(bundled).toSorted()).toEqual(["Dir", "File", "File.Delete"]); + expect(bundled.Dir).toEqual(["paired"]); + yield* useScope(); }); - it("FE23: Git's directory entry admits core's own Dir and states no identity", function* () { + it("FE23: Git offers core's own Dir by name and form, and states no identity", function* () { // What this host decides is that a workflow run has a `` at all. What - // admitting it grants is core's: the identity and the effect its paired + // offering it grants is core's: the identity and the effect its paired // spelling runs under are on the declaration that owns the component, so // there is nothing here for a host to restate or to move. - const entry = gitDirectoryEntry(); - expect(entry.name).toBe("Dir"); - expect(Object.keys(entry)).not.toContain("identity"); - expect(Object.keys(entry)).not.toContain("legacy"); - // It is the directory capability, not some other entry wearing the name. - expect(Reflect.get(entry, "capability")).toBe("directory:ensure"); + const selection = gitDirectorySelection(); + expect(Object.keys(selection)).toEqual(["Dir"]); + expect(selection.Dir).toEqual(["paired"]); + yield* useScope(); }); it("FE19: canonical `text` repeats across fresh runs without warnings", function* () { diff --git a/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts b/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts index de3192d87..9be9a0719 100644 --- a/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts +++ b/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts @@ -32,7 +32,7 @@ import type { ExecutionInstallation } from "@executablemd/core/host"; import { useDenoRepl } from "../../../../src/deno-repl.ts"; import { runReplProgram } from "../../../../src/repl/program.ts"; -import { ordinaryEvaluationProfile } from "../../../../src/evaluation-profile.ts"; +import { ordinaryGeneratedSelection } from "../../../../src/evaluation-profile.ts"; /** * The two latch paths, narrowed once at the edge. @@ -92,7 +92,8 @@ await main(function* (args) { const ran = yield* runReplProgram({ profile: { includes: [process.cwd()], - installations: [{ evaluation: ordinaryEvaluationProfile() }, holdingTeardown()], + installations: [holdingTeardown()], + generated: ordinaryGeneratedSelection(), permissionMode: "deny-all", }, ...(args[0] === undefined ? {} : { location: args[0] }), diff --git a/packages/cli/tests/fixtures/repl/reference.ts b/packages/cli/tests/fixtures/repl/reference.ts index c8921caf4..fb4d6575e 100644 --- a/packages/cli/tests/fixtures/repl/reference.ts +++ b/packages/cli/tests/fixtures/repl/reference.ts @@ -31,7 +31,7 @@ import { inlineSource } from "@executablemd/core"; import type { Operation, Stream } from "effection"; import { scoped } from "effection"; -import { ordinaryEvaluationProfile } from "../../../src/evaluation-profile.ts"; +import { useOrdinaryGeneratedSelection } from "../../../src/evaluation-profile.ts"; /** Where the entry and its nested component live. */ export const REFERENCE_DIRECTORY = fileURLToPath(new URL("./", import.meta.url)); @@ -72,10 +72,11 @@ export function runReference( { at: "min" }, ); const source = yield* referenceSource(); + yield* useOrdinaryGeneratedSelection(); try { const execution = yield* executeInstalled( { ...inlineSource(source), stream, includes: [REFERENCE_DIRECTORY] }, - [{ evaluation: ordinaryEvaluationProfile() }], + [], ); const output = yield* collect(execution); return { output: String(output), events: yield* stream.readAll(), asked, failure: undefined }; diff --git a/packages/cli/tests/fixtures/workflow-agent/approved-proposal.md b/packages/cli/tests/fixtures/workflow-agent/approved-proposal.md index a0ebb1f4c..26e862e37 100644 --- a/packages/cli/tests/fixtures/workflow-agent/approved-proposal.md +++ b/packages/cli/tests/fixtures/workflow-agent/approved-proposal.md @@ -55,9 +55,9 @@ inside ``, and not something the agent can reach. -`allow={["write"]}` selects the host's write table rather than granting -anything: a paired `` and a lexical ``, chosen before this document -existed. A proposal naming a Git push, an issue, a command or a network read +`allow={["write"]}` narrows to the writing spellings the host chose rather than +granting anything: a paired `` and a lexical ``, chosen before this +document existed. A proposal naming a Git push, an issue, a command or a network read refuses whole, and nothing it asked for happens. diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index 1806effad..9d6a978aa 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -40,21 +40,16 @@ import type { Json, SyntaxSymbols, } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import type { ComponentAnswerInstallation } from "@executablemd/core/host"; import { - executeInstalled, - fileReadEntry, - globReadEntry, - syntaxReadEntry, -} from "@executablemd/core/host"; -import type { - ComponentAnswerInstallation, - FragmentEntry, - FragmentEvaluationInput, -} from "@executablemd/core/host"; -import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; -import { elicitWriteEntry } from "@executablemd/core/host"; + ordinaryGeneratedSelection, + useOrdinaryGeneratedSelection, +} from "../src/evaluation-profile.ts"; import { useSyntax } from "@executablemd/core/host"; import { Elicitation } from "@executablemd/core"; +import type { GeneratedSelection } from "@executablemd/core/api"; +import { Component } from "@executablemd/core/api"; import { recordedFiles, useRecordedFiles } from "../../core/tests/support/fragment-files.ts"; import type { RecordedFiles } from "../../core/tests/support/fragment-files.ts"; import { answerProvider } from "../../core/tests/support/answer-provider.ts"; @@ -150,12 +145,12 @@ interface RunOptions { */ componentAnswers?: readonly ComponentAnswerInstallation[]; /** - * The evaluation ceiling this case's information requests run under. + * The names and forms this case's information requests may write. * * The command's own by default. A case supplies its own when it needs to * narrow what a request may name, or to withhold a class. */ - evaluation?: FragmentEvaluationInput; + generated?: GeneratedSelection; /** * The document filesystem this execution runs against. * @@ -231,6 +226,16 @@ function* runDocument(options: RunOptions = {}): Operation { yield* useNormalizedOutput(); try { yield* useSyntax(harness.symbols); + // What an information request may write, offered in the scope this + // execution runs in — exactly where the command offers it. A case that + // narrows the selection offers its own instead, which is how a row shows + // a name withheld rather than a table edited. + if (options.generated === undefined) { + yield* useOrdinaryGeneratedSelection(); + } else { + const stated = options.generated; + yield* Component.around({ generated: () => stated }); + } const execution = yield* executeInstalled( { ...retainedSource(PLAN_COMMAND_IDENTITY, source), @@ -246,11 +251,6 @@ function* runDocument(options: RunOptions = {}): Operation { { components: agentIdentityComponents(), declarations: [harness.declaration], - // The ceiling the command installs, so an information request this - // document evaluates reads through exactly what `xmd run` states. - // A case that supplies its own recorder gets that instead, which is - // how a row proves which operation a read actually reached. - evaluation: options.evaluation ?? ordinaryEvaluationProfile(), ...(options.files === undefined && options.installing === undefined ? {} : { @@ -808,7 +808,7 @@ describe("Tier PI — read-only information requests", () => { const enclosing: string[] = []; const run = yield* runDocument({ turns: [{ reply: COMPOSED }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, *installing(): Operation { // Nearer the provider, and transforming. @@ -852,7 +852,7 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: COMPOSED }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, }); @@ -883,7 +883,7 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: COMPOSED }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, verbose: true, }); @@ -905,7 +905,7 @@ describe("Tier PI — read-only information requests", () => { // A paired `` write under a read-only selection: refused whole, // before any read. turns: [{ reply: 'written\n' }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, }); @@ -932,7 +932,7 @@ describe("Tier PI — read-only information requests", () => { // The ninth would search a second time if it were evaluated, so the // recorder answers "was it?" rather than a count standing in for it. turns: [...turns, { reply: '\n' }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, }); @@ -967,7 +967,7 @@ describe("Tier PI — read-only information requests", () => { ], reviews: [{ decision: "Request changes", feedback: "say more" }, { decision: "Approve" }], validations: [unsound, sound], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, }); @@ -1064,7 +1064,7 @@ describe("Tier PI — read-only information requests", () => { { reply: '\n\n' }, { reply: CANDIDATE }, ], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, }); @@ -1089,7 +1089,7 @@ describe("Tier PI — read-only information requests", () => { }, { reply: CANDIDATE }, ], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, }); @@ -1103,7 +1103,7 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: COMPOSED }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, verbose: true, }); @@ -1130,7 +1130,7 @@ describe("Tier PI — read-only information requests", () => { { reply: '\n\n' }, { reply: CANDIDATE }, ], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, verbose: true, }); @@ -1169,12 +1169,11 @@ describe("Tier PI — read-only information requests", () => { * exactly that: findings are published and the next turn begins *after* * cleanup, not beside it. So this holds cleanup open and looks. * - * The barrier is an admitted `component-answer` — the same arm canonical - * protected `` is admitted through — so the protected route is - * carrying it rather than a capability core supplies the body for. Its body - * registers cleanup that blocks; while that block is held the case asserts - * the two things that must not have happened yet, then releases and asserts - * they did. + * The barrier is a selected name a provider answers for, so the resolution + * canonical capture performed is what is carrying it. Its body registers + * cleanup that blocks; while that block is held the case asserts the two + * things that must not have happened yet, then releases and asserts they + * did. * * This holds the successful settlement. The refusal path's ordering is * established elsewhere, and between them the two are covered — see the @@ -1182,11 +1181,6 @@ describe("Tier PI — read-only information requests", () => { */ const HELD_ORIGIN = "test://held-provider"; - /** The admitted entry the barrier is written as. */ - function heldEntry(): FragmentEntry { - return { kind: "component-answer", name: "Held", forms: ["self-closing"] }; - } - /** One barrier: a body whose cleanup blocks until the case releases it. */ function barrier(): { entered: ReturnType>; @@ -1245,7 +1239,12 @@ describe("Tier PI — read-only information requests", () => { { reply: '\n\n\n' }, { reply: CANDIDATE }, ], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry(), heldEntry()] }, + generated: { + File: ["self-closing"], + Glob: ["self-closing"], + Syntax: ["self-closing"], + Held: ["self-closing"], + }, files, componentAnswers: [held.provider], verbose: true, @@ -1303,7 +1302,7 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: '\n' }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, verbose: true, }); @@ -1323,7 +1322,7 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, }); @@ -1344,18 +1343,22 @@ describe("Tier PI — read-only information requests", () => { * its second site nothing. The failure names the turn and quotes the prompt, so * the case that under-scripted is identifiable from the message alone. */ -describe("Tier PI — the ordinary ceiling admits the question with the write", () => { - it("EL1: the ordinary write table holds canonical `` once, and the read table none", function* () { - const profile = ordinaryEvaluationProfile(); - - // One entry, and exactly the one core states. Two would be two grants - // under one spelling; a hand-written copy would be a second place this - // command decided what canonical `` is. - const asking = profile.write?.filter((entry) => entry.name === "Elicit") ?? []; - expect(asking).toEqual([elicitWriteEntry()]); - // And nothing in the read table: `allow={["read"]}` promises nobody will - // be interrupted, so the question is not there to select. - expect(profile.read.map((entry) => entry.name)).toEqual(["File", "Glob", "Syntax"]); +describe("Tier PI — the ordinary selection offers the question with the write", () => { + it("EL1: the ordinary selection offers `` paired, and in no other form", function* () { + const selection = ordinaryGeneratedSelection(); + + // The paired spelling and only that one. Which table it lands in is not + // this command's to say: core reads the effect off core's own `` + // declaration, which offers that form as a write — so `allow={["read"]}` + // promises nobody will be interrupted without this file deciding it. + expect(selection.Elicit).toEqual(["paired"]); + expect(Object.keys(selection).toSorted()).toEqual([ + "Elicit", + "File", + "File.Delete", + "Glob", + "Syntax", + ]); yield* useScope(); }); @@ -1381,7 +1384,7 @@ describe("Tier PI — the ordinary ceiling admits the question with the write", { reply: 'Which file?\n' }, { reply: CANDIDATE }, ], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + generated: { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }, files, }); }); diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index 3e7d1645f..da629c0c2 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -32,14 +32,9 @@ import { } from "@executablemd/core"; import type { Json, SyntaxSymbols } from "@executablemd/core"; import { content, registerComponents, validateDocument } from "@executablemd/core"; -import { - executeInstalled, - fileReadEntry, - globReadEntry, - sourceDigest, - syntaxReadEntry, -} from "@executablemd/core/host"; -import type { FragmentEvaluationInput } from "@executablemd/core/host"; +import { executeInstalled, sourceDigest } from "@executablemd/core/host"; +import { Component } from "@executablemd/core/api"; +import type { GeneratedSelection } from "@executablemd/core/api"; import { useSyntax } from "@executablemd/core/host"; import { API, Permissions } from "@executablemd/runtime"; import { recordedFiles, useRecordedFiles } from "../../core/tests/support/fragment-files.ts"; @@ -125,11 +120,11 @@ function* runDocument(options: { session?: string; props?: Record; /** - * The ceiling a generated fragment runs under, for the rows that write - * `` beside or around a ``. Absent for every other row, which - * is what makes "this execution offers no evaluation" the default. + * What a generated fragment may write, for the rows that write `` + * beside or around a ``. Absent for every other row, which is what + * makes "this host selected nothing" the default. */ - evaluation?: FragmentEvaluationInput; + generated?: GeneratedSelection; /** * The document filesystem this execution runs against. * @@ -194,6 +189,13 @@ function* runDocument(options: { } try { yield* useSyntax(harness.symbols); + // What a generated fragment may write, offered in the scope this + // execution runs in. Absent is a host that selected nothing, which is + // what makes "this execution offers no evaluation" the default here. + if (options.generated !== undefined) { + const stated = options.generated; + yield* Component.around({ generated: () => stated }); + } const execution = yield* executeInstalled( { ...retainedSource(ROOT, options.source), @@ -205,10 +207,6 @@ function* runDocument(options: { { components: agentIdentityComponents(), declarations: [harness.declaration], - // Where the profile a document observes is settled now: the - // `` the packaged Plan writes is canonical core's public - // component, and what it answers with is this execution's. - ...(options.evaluation === undefined ? {} : { evaluation: options.evaluation }), ...(options.files === undefined && options.installing === undefined ? {} : { @@ -515,8 +513,8 @@ describe("Tier PC — in an ordinary document", () => { "\n", ); - function reading(): FragmentEvaluationInput { - return { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }; + function reading(): GeneratedSelection { + return { File: ["self-closing"], Glob: ["self-closing"], Syntax: ["self-closing"] }; } /** A harness whose agent answers a request first and a Plan second. */ @@ -538,7 +536,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(), + generated: reading(), files: files, }); @@ -571,7 +569,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(), + generated: reading(), files: files, *installing(): Operation { // Nearer the provider, and transforming: installed after the @@ -625,7 +623,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(), + generated: reading(), files: files, *installing(): Operation { yield* Permissions.around({ @@ -746,7 +744,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* reading_(yield* planWriterRoot()), reviews: [], - evaluation: reading(), + generated: reading(), files: { ...files, // deno-lint-ignore require-yield @@ -809,7 +807,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness, reviews: [], - evaluation: reading(), + generated: reading(), files, }), ), @@ -879,7 +877,7 @@ describe("Tier PC — in an ordinary document", () => { ].join("\n"), harness, reviews: [], - evaluation: reading(), + generated: reading(), files: files, *installing(): Operation { yield* registerComponents([ @@ -917,7 +915,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(), + generated: reading(), files: files, // The presentation an ordinary `xmd run` installs, so a phase written // into the document would arrive here exactly as an operator would @@ -966,7 +964,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness, reviews: [], - evaluation: reading(), + generated: reading(), files: files, }); @@ -984,7 +982,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(), + generated: reading(), files: files, stream: first, }); @@ -998,7 +996,7 @@ describe("Tier PC — in an ordinary document", () => { const two = yield* runDocument({ source: SOURCE, reviews: [], - evaluation: reading(), + generated: reading(), files: tripwire, stream: first, }); @@ -1019,7 +1017,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(), + generated: reading(), files: files, stream: first, }); @@ -1033,7 +1031,7 @@ describe("Tier PC — in an ordinary document", () => { const two = yield* runDocument({ source: SOURCE, reviews: [], - evaluation: reading(), + generated: reading(), files: resumed, stream: yield* continuing(first), }); @@ -1069,7 +1067,7 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness, reviews: [], - evaluation: reading(), + generated: reading(), files: files, stream: first, }); @@ -1085,7 +1083,7 @@ describe("Tier PC — in an ordinary document", () => { const two = yield* runDocument({ source: SOURCE, reviews: [], - evaluation: reading(), + generated: reading(), files: recordedFiles({ "notes.md": "the retained note\n" }), stream: yield* continuing(first), harness: yield* planDeclarationHarness({ @@ -1137,7 +1135,7 @@ describe("Tier PC — in an ordinary document", () => { root, harness: yield* asking(root), reviews: [], - evaluation: reading(), + generated: reading(), files: files, stream: first, }); @@ -1153,7 +1151,7 @@ describe("Tier PC — in an ordinary document", () => { props: { request: "Write something else." }, root, reviews: [], - evaluation: reading(), + generated: reading(), files: changed, stream: yield* continuing(first), }); @@ -1984,9 +1982,9 @@ describe("Tier PC — in an ordinary document", () => { * type either of them shares. */ describe("Tier FE — Plan produces text, Evaluate runs it", () => { - /** A profile admitting one read, over a recorder these rows can count. */ - function profile(): FragmentEvaluationInput { - return { read: [fileReadEntry()] }; + /** One selected read, over a recorder these rows can count. */ + function profile(): GeneratedSelection { + return { File: ["self-closing"] }; } /** @@ -2002,7 +2000,15 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { const READ_MARKER = "FE713_READ_MARKER"; const WIDE_MARKER = "FE713_WIDE_MARKER"; - /** The admitted read: available inside an evaluation and at the wide site. */ + /** + * The selected read: available inside an evaluation and at the wide site. + * + * A registration of this suite's own rather than core's `` renamed. A + * host selects component names, and what a selected spelling runs as comes + * from the declaration that owns the implementation — so a fixture that + * wanted its own name in the narrowed catalog registers its own component + * and offers the generated use of its own self-closing form. + */ const READ_COMPONENT = { name: "FE713.Read", origin: "test://fe713/read", @@ -2012,6 +2018,13 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { required: ["path"], additionalProperties: false, }, + forms: ["self-closing"] as const, + generated: { + "self-closing": { + identity: "https://executable.md/components/fe713/read", + effect: "read" as const, + }, + }, description: `Reads one path. ${READ_MARKER}`, }; @@ -2024,23 +2037,14 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { }; /** - * The profile these rows evaluate under: the read entry, and never the wide - * one. + * What these rows select: the fixture's own read, and never the wide one. * - * The entry's name is the fixture's own, so the catalog an agent is shown - * inside an evaluation names `` and the admitted capability is - * core's file read behind it. + * The name is the fixture's component, so the catalog an agent is shown + * inside an evaluation names `` and what runs behind it is the + * registration this suite made. */ - function fixtureProfile(): FragmentEvaluationInput { - return { - read: [ - { - ...fileReadEntry(), - name: READ_COMPONENT.name, - description: READ_COMPONENT.description, - }, - ], - }; + function fixtureProfile(): GeneratedSelection { + return { [READ_COMPONENT.name]: ["self-closing"] }; } /** Register both fixture components at the ordinary authored site. */ @@ -2048,9 +2052,17 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { yield* registerComponents([ { ...READ_COMPONENT, - // deno-lint-ignore require-yield - *fn(): Operation { - return ""; + // The read these rows count, through the ordinary contextual provider: + // an admitted fragment reaches `API.Files` exactly where an authored + // element does, so a recorder entry is evidence that it crossed the + // same provider. + *fn(props: Record): Operation { + const cwd = yield* API.Env.operations.cwd(); + const read = yield* API.Files.operations.readTextFile({ path: String(props.path), cwd }); + if (!read.ok) { + throw read.error; + } + return read.value; }, }, { @@ -2099,7 +2111,7 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { "", ].join("\n"), reply: PROGRAM, - evaluation: profile(), + generated: profile(), files: files, }); @@ -2120,7 +2132,7 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { const run = yield* runDocument({ source: ['Write a program.', "", "done", ""].join("\n"), reply: `# Read the note\n\n\n`, - evaluation: profile(), + generated: profile(), files: files, }); @@ -2150,7 +2162,7 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { "", ].join("\n"), reply: `# Read the note\n\n\n`, - evaluation: fixtureProfile(), + generated: fixtureProfile(), files: files, }); }); @@ -2205,7 +2217,7 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { // The agent writes the component it was *not* told about. It resolves // at the ordinary site and is not in the profile. reply: `# Use the wide component\n\n\n`, - evaluation: fixtureProfile(), + generated: fixtureProfile(), files: files, }); }); @@ -2237,7 +2249,7 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { // the component only the authored site has. Titled, so it is a draft // the review approves rather than an information request. reply: `# Use the wide component\n\n\n\n`, - evaluation: fixtureProfile(), + generated: fixtureProfile(), files: files, }); }); @@ -2292,7 +2304,7 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { // information request — and it still carries the executable fence the // later, narrower `` must refuse. reply: '# Read and run\n\n\n\n```bash exec\nprintf ran\n```\n', - evaluation: profile(), + generated: profile(), files: files, }); diff --git a/packages/cli/tests/repl-agent-execution.test.ts b/packages/cli/tests/repl-agent-execution.test.ts index f5bfc028d..ea45795df 100644 --- a/packages/cli/tests/repl-agent-execution.test.ts +++ b/packages/cli/tests/repl-agent-execution.test.ts @@ -57,7 +57,7 @@ import type { ExecutionInstallation } from "@executablemd/core/host"; import { API } from "@executablemd/runtime"; -import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; import { REFERENCE_DIRECTORY } from "./fixtures/repl/reference.ts"; import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; @@ -471,7 +471,7 @@ function refusingExecution(): ReplExecution { } function installations(): readonly ExecutionInstallation[] { - return [{ evaluation: ordinaryEvaluationProfile() }, { components: agentIdentityComponents() }]; + return [{ components: agentIdentityComponents() }]; } function opened(result: Result): ReplSession { @@ -508,6 +508,7 @@ function start( return submitReplEntry({ execution: holder, installations: installations(), + generated: ordinaryGeneratedSelection(), permissionMode, source, ...(includes === undefined ? {} : { includes }), @@ -891,6 +892,7 @@ describe("L2 — exact atomic live-to-durable replacement", () => { yield* openReplSession({ execution: execution(yield* golden.stream.readAll()), installations: installations(), + generated: ordinaryGeneratedSelection(), }), ); yield* replayed.join(); @@ -978,6 +980,7 @@ describe("L3 — execution owns live work, presentation does not", () => { yield* openReplSession({ execution: execution(doctored), installations: installations(), + generated: ordinaryGeneratedSelection(), }), ); expect(refused).toBeInstanceOf(Error); @@ -1340,6 +1343,7 @@ describe("P2 — one live request, one direct settlement", () => { execution: cold, includes: [REFERENCE_DIRECTORY], installations: installations(), + generated: ordinaryGeneratedSelection(), }), ); yield* reopened.join(); diff --git a/packages/cli/tests/repl-agent-interface.test.ts b/packages/cli/tests/repl-agent-interface.test.ts index d9dcbe967..dc920a5a8 100644 --- a/packages/cli/tests/repl-agent-interface.test.ts +++ b/packages/cli/tests/repl-agent-interface.test.ts @@ -46,7 +46,7 @@ import type { } from "@executablemd/core"; import type { ExecutionInstallation } from "@executablemd/core/host"; -import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; import { agentReferenceEvents } from "./fixtures/repl/reference.ts"; import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; @@ -431,15 +431,13 @@ function* useStub(stub: Stub): Operation { */ const PROFILE: ReplExecutionProfile = { includes: [], - installations: [ - { evaluation: ordinaryEvaluationProfile() }, - { components: agentIdentityComponents() }, - ], + installations: [{ components: agentIdentityComponents() }], + generated: ordinaryGeneratedSelection(), permissionMode: "approve-reads", }; function installations(): readonly ExecutionInstallation[] { - return [{ evaluation: ordinaryEvaluationProfile() }, { components: agentIdentityComponents() }]; + return [{ components: agentIdentityComponents() }]; } function execution(): ReplExecution { @@ -956,6 +954,7 @@ function* asking( yield* submitReplEntry({ execution: holder, installations: installations(), + generated: ordinaryGeneratedSelection(), permissionMode, source, }), @@ -1617,7 +1616,11 @@ describe("U2 — one action path, and one owner for a pending request", () => { stream: new InMemoryStream([...(yield* agentReferenceEvents())]), }; const session = granted( - yield* openReplSession({ execution: holder, installations: installations() }), + yield* openReplSession({ + execution: holder, + installations: installations(), + generated: ordinaryGeneratedSelection(), + }), ); const recordedAudits = session.model.turns.flatMap((turn) => turn.permissions); expect(recordedAudits.length).toBeGreaterThan(0); @@ -3310,6 +3313,7 @@ describe("U9 — two entries whose Prompts share one durable name", () => { yield* submitReplEntry({ execution: holder, installations: installations(), + generated: ordinaryGeneratedSelection(), permissionMode: "deny-all", source: onePromptAt("one"), }), @@ -3412,6 +3416,7 @@ describe("EU1 — selecting an entry moves the transcript locus and nothing else yield* submitReplEntry({ execution: holder, installations: installations(), + generated: ordinaryGeneratedSelection(), permissionMode: "deny-all", source: onePromptAt("one"), }), diff --git a/packages/cli/tests/repl-agent-journey.test.ts b/packages/cli/tests/repl-agent-journey.test.ts index 623df7163..e53ae4d63 100644 --- a/packages/cli/tests/repl-agent-journey.test.ts +++ b/packages/cli/tests/repl-agent-journey.test.ts @@ -445,17 +445,20 @@ describe("C1 — the command line this REPL runs under", () => { // Fixed includes: this command takes no include option, because an entry is // typed rather than named. expect(settled.includes).toEqual(["components", "."]); - // One installation of this command's own, holding the packaged `` and - // the ceiling a generated fragment runs under — and the ordinary write - // table, questions included. + // One installation of this command's own, holding the packaged ``. expect(settled.installations).toHaveLength(1); const own = settled.installations[0]!; expect((own.declarations ?? []).map((declaration) => declaration.name)).toEqual(["Plan"]); - expect((own.evaluation?.write ?? []).map((entry) => entry.name)).toEqual([ - "File", - "File.Delete", - "Elicit", - ]); + // And the selection a generated fragment runs under, carried as data on the + // profile rather than as an installation: the session offers it in each + // entry's own execution scope, because that is the scope core reads it in. + expect(settled.generated).toEqual({ + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + Glob: ["self-closing"], + Syntax: ["self-closing"], + Elicit: ["paired"], + }); // Read once and never changed: a profile a later entry could edit would be // two sets of rules in one session. expect(Object.isFrozen(settled)).toBe(true); diff --git a/packages/cli/tests/repl-execution.test.ts b/packages/cli/tests/repl-execution.test.ts index a7535d0d0..60e576ca3 100644 --- a/packages/cli/tests/repl-execution.test.ts +++ b/packages/cli/tests/repl-execution.test.ts @@ -27,7 +27,8 @@ import { appendFile, open } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; +import { selecting } from "../../core/tests/support/generated-selection.ts"; import { COMPONENT_BOUNDARIES, EXECUTION_BOUNDARIES, @@ -54,7 +55,13 @@ import { referenceSource, } from "./fixtures/repl/reference.ts"; -const INSTALLATIONS = [{ evaluation: ordinaryEvaluationProfile() }]; +/** + * What a generated fragment may write in every entry this suite opens. + * + * The selection rather than an installation: the session offers it in each + * entry's own execution scope, which is the scope core reads it in. + */ +const GENERATED = ordinaryGeneratedSelection(); /** * A Node-backed host, installed by the test rather than by the kernel. @@ -135,7 +142,7 @@ function execution(events: readonly DurableEvent[] = []): ReplExecution { } function options(execution: ReplExecution) { - return { execution, includes: [REFERENCE_DIRECTORY], installations: INSTALLATIONS }; + return { execution, includes: [REFERENCE_DIRECTORY], generated: GENERATED }; } function opened(result: Result): ReplSession { @@ -912,6 +919,70 @@ describe("REPL execution: one execution's file", () => { }); }); +describe("REPL execution: the selection each entry reads", () => { + beforeAll(() => useTempFileCompiler()); + + /** + * One entry whose fragment writes a name the ordinary selection offers. + * + * `` rather than a file, because what the row is about is which + * selection the entry read: a refusal here is an admission refusal and + * nothing else touches a filesystem, a provider or a question. + */ + const DESCRIBING = '\n'; + + it("X1: the session's selection is the entry's default, and a host's own is not replaced", function* () { + // Control: no host states a selection, so the entry runs under the one the + // command supplies and the fragment is admitted. + const ordinary = opened( + yield* submitReplEntry({ ...options(execution()), source: DESCRIBING }), + ); + const settled = yield* ordinary.join(); + expect(settled.ok).toBe(true); + expect(ordinary.model.terminal?.status).toBe("ok"); + + // The same entry, where a host installation states its own. It is defined, + // narrower, and nearer than the session's default — so it is what core + // reads, and the same fragment is refused for the name it writes. + const narrowed = opened( + yield* submitReplEntry({ + ...options(execution()), + source: DESCRIBING, + installations: [selecting({ Glob: ["self-closing"] })], + }), + ); + const refused = yield* narrowed.join(); + expect(refused.ok).toBe(false); + expect(refused.ok ? "" : refused.error.message).toContain("did not admit"); + expect(narrowed.model.terminal?.status).toBe("err"); + }); + + it("X1: a host's defined empty selection refuses the entry before its body", function* () { + const holder = execution(); + const entry = opened( + yield* submitReplEntry({ + ...options(holder), + source: DESCRIBING, + installations: [selecting({})], + }), + ); + + // Defined and empty is a host statement, not an absence: core refuses the + // setup rather than this session answering over it with its default. + const settled = yield* entry.join(); + expect(settled.ok).toBe(false); + expect(settled.ok ? "" : settled.error.message).toContain("selected no component"); + // Refused before the entry body: the document rendered nothing and the + // fragment was never admitted. + expect(entry.model.terminal?.output ?? "").toBe(""); + expect( + (yield* readAll(holder)).filter( + (event) => event.type === "yield" && event.description.type === "generated_xmd", + ), + ).toEqual([]); + }); +}); + describe("execution initialization in REPL entries", () => { it("EI-REPL: two entries own fresh state below the same session attachment", function* () { const State = createContext("executablemd.test.repl.init", undefined); diff --git a/packages/cli/tests/repl-forms.test.ts b/packages/cli/tests/repl-forms.test.ts index 152fb106a..255f7dc90 100644 --- a/packages/cli/tests/repl-forms.test.ts +++ b/packages/cli/tests/repl-forms.test.ts @@ -64,7 +64,7 @@ import type { ReplTree } from "../src/repl/reconcile.ts"; import { submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; import type { ReplExecution } from "../src/repl/journal.ts"; -import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; import { referenceEvents } from "./fixtures/repl/reference.ts"; /** The widest accepted frame, stated here because layout keeps it private. */ @@ -1140,7 +1140,8 @@ describe("F3 — focus returns to the invocation, not to where the drawer came f const session = granted( yield* submitReplEntry({ execution: holder, - installations: [{ evaluation: ordinaryEvaluationProfile() }], + installations: [], + generated: ordinaryGeneratedSelection(), source: THREE_QUESTIONS, }), ); diff --git a/packages/cli/tests/repl-journey.test.ts b/packages/cli/tests/repl-journey.test.ts index 5f9795a01..678657605 100644 --- a/packages/cli/tests/repl-journey.test.ts +++ b/packages/cli/tests/repl-journey.test.ts @@ -35,7 +35,8 @@ import { until } from "effection"; import { API } from "@executablemd/runtime"; import { Elicitation, useTempFileCompiler } from "@executablemd/core"; -import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; +import type { ExecutionInstallation } from "@executablemd/core/host"; import { installReplHost } from "../src/repl-assembly.ts"; import { installReplTerminal } from "../src/repl/terminal-host.ts"; import type { ReplTerminalCapabilities } from "../src/repl/terminal-host.ts"; @@ -90,8 +91,13 @@ const EMPTY_MODEL_FOR_TEST = Object.freeze({ }); const TEXT = new TextDecoder(); -/** What this host installs around the reference entry. */ -const INSTALLATIONS = [{ evaluation: ordinaryEvaluationProfile() }]; +/** + * What this host installs around the reference entry. + * + * Nothing. What a generated fragment may write is on the profile beside this, + * as the selection the session offers in each entry's own execution scope. + */ +const INSTALLATIONS: readonly ExecutionInstallation[] = []; /** * The profile this suite's REPL runs under. @@ -104,6 +110,7 @@ const INSTALLATIONS = [{ evaluation: ordinaryEvaluationProfile() }]; const PROFILE: ReplExecutionProfile = { includes: [REFERENCE_DIRECTORY], installations: INSTALLATIONS, + generated: ordinaryGeneratedSelection(), permissionMode: "deny-all", }; diff --git a/packages/cli/tests/repl-presentation-style.test.ts b/packages/cli/tests/repl-presentation-style.test.ts index 87b1481c7..caa4e2c6a 100644 --- a/packages/cli/tests/repl-presentation-style.test.ts +++ b/packages/cli/tests/repl-presentation-style.test.ts @@ -86,7 +86,7 @@ import { useCountingRenderer } from "./fixtures/repl/presentation.ts"; import { useReplTree } from "../src/repl/reconcile.ts"; import { createFitStore } from "../src/repl/fitting.ts"; import { commitReplFrame } from "../src/repl/program.ts"; -import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; import { submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; import type { CellStyle, Observed, Presenter, TerminalGrid } from "./fixtures/repl/presentation.ts"; @@ -1771,7 +1771,8 @@ describe("REPL presentation: a frame of a real held invocation", () => { scoped(function* () { const opened = yield* submitReplEntry({ execution: { id: "held", stream: new InMemoryStream([]) }, - installations: [{ evaluation: ordinaryEvaluationProfile() }], + installations: [], + generated: ordinaryGeneratedSelection(), source: HELD_SOURCE, }); if (!opened.ok) { diff --git a/packages/cli/tests/workflow-agent.test.ts b/packages/cli/tests/workflow-agent.test.ts index 84477ac25..d1479e780 100644 --- a/packages/cli/tests/workflow-agent.test.ts +++ b/packages/cli/tests/workflow-agent.test.ts @@ -13,7 +13,7 @@ * rather than restated here: a fixture a test rewrites is a fixture nobody runs. */ -import { gitDirectoryEntry } from "@executablemd/git"; +import { gitDirectorySelection } from "@executablemd/git"; import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { scoped, spawn } from "effection"; @@ -29,12 +29,13 @@ import { API, useHostFiles } from "@executablemd/runtime"; import { DatabaseSync } from "node:sqlite"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { - evaluationProfile, transactAgentPromptCheckpoints, + useWorkflowGenerated, withWorkflowWorkspace, - workflowRunPath, workflowProviderSessions, + workflowRunPath, } from "@executablemd/workflow/deno"; +import type { WorkflowRequestBound } from "@executablemd/workflow/deno"; import { executeInstalled } from "@executablemd/core/host"; import { agentIdentityComponents } from "@executablemd/core"; import { useWorkflowAgentProfile, WORKFLOW_SESSION_INSTRUCTIONS } from "../src/workflow-agent.ts"; @@ -196,8 +197,8 @@ function runFixture( * forgot rather than a process that restarted. */ readonly sessionStore?: AgentProfileOptions["sessionStore"]; - /** What this host admits a generated fragment under, beyond the standard profile. */ - readonly evaluation?: { readonly requests?: readonly Record[] }; + /** What this host bounds a generated fragment's requests to, when it bounds any. */ + readonly requests?: readonly WorkflowRequestBound[]; /** The root props this run was started with. */ readonly props?: Record; /** Answers one admitted HTTP read, and counts what was asked for. */ @@ -250,6 +251,14 @@ function runFixture( output = yield* withWorkflowWorkspace( database, scoped(function* () { + // This fixture stands in for the bundled XMD workflow host, which + // supplies Git's `` spelling and bounds what a request may + // reach. Offered inside the attachment, in the scope the document + // runs in. + yield* useWorkflowGenerated({ + components: gitDirectorySelection(), + ...(options.requests === undefined ? {} : { requests: options.requests }), + }); return yield* collect( yield* executeInstalled( { @@ -259,18 +268,9 @@ function runFixture( }, // The Agent words name durable work after their own invocations, // so the host declares them to the execution. `` is - // canonical core's: what the host states for it is the ceiling. - [ - { - components: [...agentIdentityComponents()], - evaluation: yield* evaluationProfile(database, { - // This harness stands in for the bundled XMD workflow - // profile, which supplies Git's directory entry. - directory: gitDirectoryEntry(), - ...(options.evaluation ?? {}), - }), - }, - ], + // canonical core's: what the host offers for it is the selection, + // which this fixture installs in the executing scope above. + [{ components: [...agentIdentityComponents()] }], ), ); }), @@ -555,7 +555,7 @@ describe("Tier WAL — the workflow Agent observation loop", () => { const attempt = yield* runFixture(root, database, source, { createRuntime: fake.create, - evaluation: { requests: [{ url }] }, + requests: [{ url }], transport, }); @@ -602,7 +602,7 @@ describe("Tier WAL — the workflow Agent observation loop", () => { const first = yield* runFixture(root, database, source, { createRuntime: live.create, sessionStore: store, - evaluation: { requests: [{ url }] }, + requests: [{ url }], transport: { performed }, }); expect(first.failure).toBe(undefined); @@ -618,7 +618,7 @@ describe("Tier WAL — the workflow Agent observation loop", () => { const replay = yield* runFixture(root, database, source, { createRuntime: tripwireAcp((what) => reached.push(what)), sessionStore: store, - evaluation: { requests: [{ url }] }, + requests: [{ url }], transport: { performed: replayed }, }); diff --git a/packages/cli/tests/workflow-installation.test.ts b/packages/cli/tests/workflow-installation.test.ts index f4bf80248..b9ef44377 100644 --- a/packages/cli/tests/workflow-installation.test.ts +++ b/packages/cli/tests/workflow-installation.test.ts @@ -28,6 +28,8 @@ import { } from "@executablemd/workflow/deno"; import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno"; import { WorkflowLifecycle, WorkflowRunStorage } from "@executablemd/workflow"; +import { Component } from "@executablemd/core/api"; +import type { GeneratedSelection } from "@executablemd/core/api"; import { GitQuery } from "@executablemd/git/api"; import type { WorkflowRunDatabase, WorkflowRunStatus } from "@executablemd/workflow"; import { runWorkflow } from "../src/workflow.ts"; @@ -258,6 +260,7 @@ describe("Tier WFI — what a run hands to canonical core", () => { const attached: string[] = []; const seen: WorkflowExecution[] = []; let executions = 0; + let offered: GeneratedSelection | undefined; yield* scoped(function* () { const root = yield* useRunStore(); @@ -270,6 +273,10 @@ describe("Tier WFI — what a run hands to canonical core", () => { function* (execution): Operation> { executions += 1; seen.push(execution); + // Read inside the attachment, which is the scope the document runs + // in: the selection reaches core as the contextual answer there + // rather than as an installation member. + offered = yield* execution.around(Component.operations.generated); return Ok(undefined); }, ); @@ -290,23 +297,6 @@ describe("Tier WFI — what a run hands to canonical core", () => { ); expect(preparing.length).toEqual(1); expect(preparing[0]?.admissions?.length).toEqual(1); - // Every installation this run was given is one of three things, and none of - // them is a second execution: one `executeInstalled()`, not one per phase. - // A run-contract installation carries its admission; a bundle carries its - // own admission and no preparation; and the fragment-evaluation profile - // carries a - // ceiling and no admission at all, because stating what a generated - // fragment may do is not a claim about this run's history. - const profiles = (execution?.installations ?? []).filter( - (candidate) => candidate.evaluation !== undefined, - ); - for (const candidate of execution?.installations ?? []) { - if (candidate.evaluation !== undefined) { - expect(candidate.admissions).toBe(undefined); - expect(candidate.prepare).toBe(undefined); - expect(candidate.components).toBe(undefined); - } - } // The exact contribution, rather than a rule each one satisfies: this run // installs no bundle, so what the *workflow command* hands core is the run // contract's one admission and nothing else. @@ -319,7 +309,6 @@ describe("Tier WFI — what a run hands to canonical core", () => { // what it sees is the run's own contribution alone. expect( (execution?.installations ?? []) - .filter((candidate) => candidate.evaluation === undefined) .map((candidate) => candidate.admissions?.length ?? 0) // `sort`, not `toSorted`: the Node typecheck's lib is ES2022 and // `toSorted` is ES2023. `map` already returned a fresh array, so @@ -327,12 +316,17 @@ describe("Tier WFI — what a run hands to canonical core", () => { .sort((left, right) => left - right), ).toEqual([1]); - // The ceiling is stated exactly once, and it is a real one: a run that - // installed no profile, or an empty one, would leave `` with - // nothing to narrow and is the regression this asserts against rather than - // merely tolerating a third shape. - expect(profiles).toHaveLength(1); - expect((profiles[0]?.evaluation?.read ?? []).length).toBeGreaterThan(0); + // The selection is offered exactly once, inside the attachment, and it is a + // real one: a run that offered none, or an empty one, would leave + // `` with nothing to narrow and is the regression this asserts + // against rather than merely tolerating a third shape. It is not an + // installation at all — a host composes one contextual answer, and + // `ExecutionInstallation` carries no member for it. + expect(offered).toEqual({ + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + Dir: ["paired"], + }); // And nothing declares ``. It is canonical core's own protected // component, so a run states the profile it narrows from and never the diff --git a/packages/core/api.ts b/packages/core/api.ts index 531aaec45..e4021405a 100644 --- a/packages/core/api.ts +++ b/packages/core/api.ts @@ -126,6 +126,7 @@ export type { ComponentApi, ComponentExpansionPhase, ComponentExpansionRequest, + GeneratedSelection, } from "./src/component-api.ts"; export type { Expansion } from "./src/expansion.ts"; diff --git a/packages/core/host.ts b/packages/core/host.ts index df68282b6..4558e1520 100644 --- a/packages/core/host.ts +++ b/packages/core/host.ts @@ -18,18 +18,19 @@ * entered at all. They are what lets a host — the workflow package, for one — * prepare a run in the journal that the document then runs against. * - * A third act of infrastructure sits beside them. **Generated XMD** is source - * an Agent produced, and `evaluateGeneratedXmd()` is how a trusted host runs it: - * the complete fragment is preflighted before its first effect, only the pinned - * identity the host admitted for that name *and* that authored form may - * execute, and what was admitted is recorded as one ordinary durable event - * before the first generated effect. The host states a `read` table and a - * `write` table and the caller selects between them, so admitting an - * observation is not admitting a mutation. It is an `Operation`: the - * production workflow reaches it through its host-declared `` - * component inside the owning authored expansion — not through a - * `DurablePreparation` — so the admission and every durable effect the - * admitted fragment performs belong to that expansion's own durable sequence. + * A third act of infrastructure sits beside them, and it is not on this module + * at all. **Generated XMD** is source an Agent produced, and what a host + * contributes to running it is one selection of component names and authored + * forms, composed through the ordinary `Component.generated` property on + * `@executablemd/core/api`. Core reads that selection once before public + * execution policy, resolves each selected name through the ordinary import + * chain, and derives the identity, the effect and the contract of every + * selected spelling from the declaration that owns the implementation. The + * complete fragment is then preflighted before its first effect, and what was + * admitted is recorded as one ordinary durable event before the first generated + * effect — inside the owning authored expansion of the public `` + * element, so the admission and every effect belong to that expansion's own + * durable sequence. * * Both stop at the first refusal, and a refusal stops only what has not * happened yet: durable effects an earlier preparation completed stay @@ -83,34 +84,16 @@ export type { JournalAdmission, } from "./src/execute.ts"; /** - * What a trusted host states about generated-fragment evaluation. - * - * Only the input a host writes, and only here, because this is the trusted - * surface `ExecutionInstallation` already lives on. What canonical execution - * captures from it — the bound operations and their revocation — is unexported: - * ordinary core publishes no getter for the active permissions and no way to install - * a provider. - */ -export type { - CapabilityEntry, - ComponentAnswerEntry, - FragmentEntry, - FragmentEvaluationInput, - FragmentForm, - FragmentWorkspaceAccess, -} from "./src/evaluation-profile.ts"; -/** - * How a trusted host supplies the implementation behind a `component-answer` - * entry. - * - * An installer, not a definition: canonical execution runs it during profile - * capture and hands it a registrar fixed to this installation's origin. The - * registrar composes import middleware, and every invocation of that middleware - * receives its own request — fixed to the name and position it was asked, and - * closed when that invocation ends — which is the only thing that states what - * the provider is returning. Canonical execution then decides whether that is - * what the profile admitted, and resolves the name itself through the ordinary - * import chain. + * How a trusted host supplies the implementation behind a selected name. + * + * An installer, not a definition: canonical execution runs it before the + * generated selection is captured and hands it a registrar fixed to this + * installation's origin. The registrar composes import middleware, and every + * invocation of that middleware receives its own request — fixed to the name + * and position it was asked, and closed when that invocation ends — which is + * the only thing that states what the provider is returning. Canonical + * execution then decides whether that is an implementation it may retain, and + * resolves the name itself through the ordinary import chain. */ export type { ComponentAnswerHandler, @@ -118,17 +101,6 @@ export type { ComponentAnswerRegistrar, ComponentAnswerRequest, } from "./src/component-answers.ts"; -/** - * Which component an admitted entry runs. - * - * A name a host admits, and nothing about how the operation behind it is - * performed: an admitted entry runs canonical core's own ordinary component, - * through whichever `API.Files` or `API.Fetch` provider is installed where the - * fragment runs — see `src/fragment-capabilities.ts`. A host that evaluates - * against its own filesystem installs that provider; it hands no operation to - * the profile. - */ -export type { FragmentCapability } from "./src/fragment-capabilities.ts"; /** * The response shape a retained request answers with, and the detaching a host * needs to read one. @@ -140,26 +112,16 @@ export type { FragmentCapability } from "./src/fragment-capabilities.ts"; export type { FetchResponseRecord } from "./src/fetch-response.ts"; export { detachHeaders, detachStatus } from "./src/fetch-response.ts"; /** - * Core's own entries, for the hosts that admit them. - * - * Constructors rather than a table a host assembles from `CORE_REGISTRY`, - * because each of them states a constraint the registry does not hold: which - * spelling of `` is being admitted, that admitting `` requires the - * exact requests it may perform, and that `` is admitted as the - * answer canonical resolution gives for it rather than as a body core supplies - * a second time. + * Why canonical execution could not capture a host's generated selection. + * + * The selection itself is stated through `Component.generated`, which is an + * ordinary contextual property on the consumer surface rather than a host + * input: a host composes middleware that answers with the names and forms it + * offers, and core derives everything else from the declarations that own those + * implementations. What stays here is the refusal a host is held to. */ -export { - directoryEntry, - EvaluationProfileError, - fetchEntry, - fileDeleteEntry, - fileReadEntry, - fileWriteEntry, - elicitWriteEntry, - globReadEntry, - syntaxReadEntry, -} from "./src/evaluation-profile.ts"; +export { EvaluationProfileError } from "./src/evaluation-profile.ts"; +export { GeneratedSelectionError } from "./src/generated-selection.ts"; /** * Whether a failure is core refusing the generated request itself — see * `src/generated-request-refusal.ts`. @@ -315,25 +277,7 @@ export type { * author. */ export { installInvocationAgentProvider } from "./src/agent/launch-install.ts"; -export { - evaluateGeneratedXmd, - GeneratedXmdError, - pinnedComponent, - pinnedFetch, - pinnedFileDelete, - pinnedFileRead, - pinnedFileWrite, - pinnedMutation, -} from "./src/generated-xmd.ts"; -export type { - GeneratedComponentForm, - GeneratedEffectClass, - GeneratedImplementation, - GeneratedMutation, - GeneratedObservation, - GeneratedRequest, - GeneratedXmdRequest, -} from "./src/generated-xmd.ts"; +export { GeneratedXmdError } from "./src/generated-xmd.ts"; /** * Where a completed Prompt publishes, for a host that retains something beside diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 17778873f..9a5f0a39f 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -48,7 +48,7 @@ export { combine } from "@effectionx/middleware"; export type { ModifierFactory, ModifierMiddleware, CodeBlockWorkflow } from "./src/modifiers.ts"; export { useCodeBlock } from "./src/modifiers.ts"; -export type { ComponentApi } from "./src/component-api.ts"; +export type { ComponentApi, GeneratedSelection } from "./src/component-api.ts"; export { Component, importComponent, @@ -57,6 +57,7 @@ export { raise, env, evalScope, + generated, codeBlock, persistent, content, diff --git a/packages/core/src/component-api.ts b/packages/core/src/component-api.ts index e534b4f13..2c7e63611 100644 --- a/packages/core/src/component-api.ts +++ b/packages/core/src/component-api.ts @@ -20,6 +20,7 @@ import { settle } from "./errors.ts"; import { ComponentExpansionProtocolError } from "./component-expansion.ts"; import type { BoundExecRequest } from "./bound-exec.ts"; import type { Expansion } from "./expansion.ts"; +import type { InvocationForm } from "./invocation-identity.ts"; import type { CodeBlockContext, CodeBlockResult, @@ -81,7 +82,36 @@ export interface ComponentExpansionRequest { readonly phases: Stream; } +/** + * The component forms a host offers to generated XMD, by name. + * + * Names and spellings only. Which URL each selected spelling runs under, what + * it is validated against, and whether it observes or mutates belong to the + * declaration that owns the implementation, so a host chooses what a fragment + * may write and never what writing it grants. + */ +export type GeneratedSelection = Readonly>; + export interface ComponentApi { + /** + * The component forms this host offers to generated XMD (spec §5.3.2). + * + * A plain value property: a consumer yields `Component.operations.generated` + * without parentheses, and middleware composes one complete selection rather + * than contributing to a collection. Core reads the final answer once, after + * initialization and the captured installation callbacks have assembled the + * host and before public `Execution.execute` policy, copies it, validates all + * of it, and resolves each selected name once. A later policy that supplies a + * broader value, replaces a registration or imports a different body changes + * neither the captured vocabulary nor the captured implementations. + * + * `undefined` is the default and preserves absence: the host offers no + * generated evaluation, so a document with no `` runs and an + * `` refuses before its producer. A defined selection names at + * least one component, and a malformed one refuses setup rather than being + * repaired, filtered or replaced with a default. + */ + readonly generated: GeneratedSelection | undefined; /** * Surround one executable element's complete expansion (spec §5.7). * @@ -314,6 +344,12 @@ export function isMissingImportProvider(error: unknown, asked: string): boolean * process, and the lint rule that says so is right. */ const COMPONENT_DEFAULTS: Omit = { + /** + * No generated evaluation at all, which is a different statement from + * offering an empty one: a host that selected nothing has not stated a + * selection core could validate and refuse. + */ + generated: undefined, /** * Nothing issued this, so there is nothing to surround. * @@ -498,6 +534,7 @@ export const applyModifiers: Operations["applyModifiers"] = export const applyBoundModifiers: Operations["applyBoundModifiers"] = Component.operations.applyBoundModifiers; export const raise: Operations["raise"] = Component.operations.raise; +export const generated: Operations["generated"] = Component.operations.generated; export const env: Operations["env"] = Component.operations.env; export const evalScope: Operations["evalScope"] = Component.operations.evalScope; export const codeBlock: Operations["codeBlock"] = Component.operations.codeBlock; diff --git a/packages/core/src/components/Evaluate.ts b/packages/core/src/components/Evaluate.ts index bab37f9b9..9052facdd 100644 --- a/packages/core/src/components/Evaluate.ts +++ b/packages/core/src/components/Evaluate.ts @@ -217,13 +217,12 @@ function evaluate(claim: IdentityClaimant): ProtectedBody { const narrowedSyntax = narrow(site.syntax, entries); const source = stated === undefined ? yield* project(site, narrowedSyntax) : stated; - // Read after the producer has rendered, and exactly once per occurrence: a - // producer may itself commit mutations, and the basis this admission is - // made under is the run's basis at the moment the program exists. + // Checked after the producer has rendered: a producer may itself commit + // mutations, and what this admission needs is that the implementations it + // captured still belong to a live execution. if (!profile.live()) { throw new ComponentInvocationError(REVOKED); } - const basis = profile.workspace === undefined ? undefined : yield* profile.workspace.snapshot(); // Where the authored element was written, as diagnostic journal data beside // the admission. A generated fragment's own elements are scanned from a @@ -236,7 +235,6 @@ function evaluate(claim: IdentityClaimant): ProtectedBody { allow, observations: entries.observations, ...(entries.mutations.length === 0 ? {} : { mutations: entries.mutations }), - ...(basis === undefined ? {} : { workspaceRoots: basis.roots, selectedRoot: basis.current }), ...(expansion.position === undefined ? {} : { position: expansion.position }), }; // Where this fragment's relative paths resolve is the contextual working @@ -360,7 +358,6 @@ function selectedTables( ...(entry.forms.length === 1 && entry.forms[0] === "self-closing" ? { selfClosing: true } : {}), - ...(entry.requests === undefined ? {} : { requests: entry.requests.map(asRequest) }), }); admitted.push(entry); } @@ -386,31 +383,15 @@ function selectedTables( /** * What kind of thing is behind one captured entry's name. * - * It travels with the entry because an operation core supplies the body for and - * an answer the import chain resolved are different grants even under one - * identity: the first cannot be composed around and the second was resolved - * through middleware. The identity itself is not restated here — it is on the - * definition the entry already carries, where its owner declared it. + * Every selected entry is an answer the import chain resolved, because that is + * the one way a selected name reaches an implementation now. It travels with + * the entry rather than being inferred at the record, so a reader comparing two + * admissions reads what each row was retained as. */ function implementation(entry: CapturedEntry): GeneratedImplementation { return entry.kind === "component-answer" ? "component-answer" : "capability"; } -/** One captured ceiling, as the request record the evaluator compares against. */ -function asRequest(request: { - url: string; - method: string; - headers: Record; - timeout?: number; -}): Record { - return { - url: request.url, - method: request.method, - headers: { ...request.headers }, - ...(request.timeout === undefined ? {} : { timeout: `${request.timeout}ms` }), - }; -} - /** * The reference a producer renders under, or nothing when the site carries none. * diff --git a/packages/core/src/components/components.md b/packages/core/src/components/components.md index 2c0bdf961..a63a87642 100644 --- a/packages/core/src/components/components.md +++ b/packages/core/src/components/components.md @@ -100,24 +100,23 @@ invoked them, with whatever the fragment rendered kept beside them under ### `allow` narrows; it never grants -Writing `` grants nothing. Every ceiling was stated by the host that -assembled this run, before any document existed — which components a fragment -may name, which spelling of each, and the exact requests any of them may -perform. `allow` names an effect *class* and selects among the tables that -already exist: `read` observes, `write` mutates. Omitting it asks for `read`. A -class the host installed nothing for is refused before the program is read. +Writing `` grants nothing. What a fragment may name — which +components, in which spelling — was chosen by the host that assembled this run, +before any document existed. `allow` names an effect *class* and narrows to the +spellings that reach it: `read` observes, `write` mutates. Omitting it asks for +`read`. A class no chosen spelling reaches is refused before the program is +read. -An execution whose host stated no evaluation profile has no evaluation at all, -and says so when the element is written rather than at startup. +An execution whose host chose nothing has no evaluation at all, and says so when +the element is written rather than at startup. ### What a fragment may contain -Much less than a document. The evaluator admits the host's pinned components and +Much less than a document. The evaluator admits the components the host chose and plain text, and refuses the whole fragment — before its first effect — for an executable code block, an expression prop, an interpolated binding, an `as` -binding, any structural construct, a component the host did not admit, a -spelling the host did not admit that component for, or a request outside the -stated ceiling. +binding, any structural construct, a component the host did not choose, or a +spelling the host did not choose that component for. A producer written as content is told exactly this: it renders under the narrowed symbols, so `` inside it lists what a fragment may write @@ -127,7 +126,7 @@ rather than what the surrounding document may write. One occurrence is one durable admission. A continuation restores the decision that was made rather than making it again, and refuses if the run now offers -different text, or states ceilings the admission was not granted under. +different text, or chooses components the admission was not granted under. ## Elicit diff --git a/packages/core/src/evaluation-profile.ts b/packages/core/src/evaluation-profile.ts index 8ccf60383..4cb779397 100644 --- a/packages/core/src/evaluation-profile.ts +++ b/packages/core/src/evaluation-profile.ts @@ -1,416 +1,84 @@ /** - * What a trusted host allows a generated fragment to do, stated before any - * document code exists. + * What a generated fragment may do here, derived from the host's selection and + * from the declarations that own the implementations. * * `` is a public component: any author may write it, and canonical * core owns what it means. That settles *which implementation runs* and nothing * else — protection grants no permission. Canonical `` admits only - * the entries already captured in this profile, selected by `allow`. So the - * ceiling lives here, in a value the host hands canonical execution at the - * installation boundary, before a document exists to ask for it. - * - * ## Host input and what execution captures are two shapes - * - * A host states {@link FragmentEvaluationInput}: entry tables, an optional - * Workspace snapshot operation, and whether it accepts the released `source` - * spelling. Canonical execution turns that into a captured profile of its own - * — every value deep-copied and frozen, every admitted implementation resolved - * once and held to this execution — and keeps the captured one. A host that - * mutates its own arrays, headers or tables after installation changes nothing - * about the execution, because the execution is no longer reading them. - * - * Only the input type is exported, and only from the trusted `core/host` - * surface, because that is where `ExecutionInstallation` already is. The - * captured profile, its bound operations and its revocation are unexported: - * ordinary core publishes no getter for the active profile and no way to install - * a provider. - * - * ## One per execution - * - * Two profiles would be two answers to "what may a fragment do here", and - * choosing between them by installation order would make permission depend on - * assembly. So an execution accepts one and refuses two — even two identical - * ones, because a host that stated it twice has an assembly nobody validated. - * A host that stated none offers no evaluation at all, which `` + * the entries captured here, selected by `allow`. So the ceiling is established + * before public execution policy, from two inputs neither of which is a + * contract table: + * + * - the host's `Component.generated` selection, which names components and + * authored forms (`generated-selection.ts`); and + * - each selected name's own resolved definition, which states the identity and + * the effect every offered spelling runs under + * (`components/generated-metadata.ts`). + * + * A host therefore chooses *what a fragment may write* and never *what writing + * it grants*. There are no host-stated schemas, descriptions, identities, + * dispatchers or operations, and no second filesystem: an admitted name runs + * the implementation canonical resolution answered with, performing its + * ordinary operation through contextual `API.Files` and `API.Fetch` under the + * provider installed where the fragment runs and the effective `Permissions` of + * the consuming scope. + * + * ## One per execution, captured once + * + * Normal contextual composition answers with one final selection, so there is + * no count of contributors and no two-profile refusal: a host assembly is + * responsible for composing the value it intends to offer. Core reads that + * answer once, resolves each selected name once, and keeps what it captured. A + * later policy that supplies a broader selection, replaces a registration or + * imports a different body changes nothing captured here. + * + * A host that selected nothing offers no evaluation at all, which `` * refuses with when it is invoked rather than at installation: a document that * never writes `` is not asking for a ceiling. */ import type { Operation } from "effection"; -import { CORE_ORIGIN, ELICIT_COMPONENT } from "./components/registry.ts"; -import { SYNTAX_COMPONENT } from "./components/Syntax.ts"; -import { - CAPABILITY_FORMS, - capabilityImplementation, - capabilityProps, - captureCapabilities, - FragmentCapabilityError, - REVOKED_CAPABILITY, -} from "./fragment-capabilities.ts"; -import type { - CapabilityImplementation, - CapturedCapabilities, - FragmentCapability, -} from "./fragment-capabilities.ts"; +import { CORE_ORIGIN } from "./components/registry.ts"; +import { DirInvocationError } from "./components/Dir.ts"; +import { FileAccessError } from "./components/File.ts"; +import { FileDeleteError } from "./components/FileDelete.ts"; +import { GlobError } from "./components/Glob.ts"; +import { markGeneratedRequestRefusal } from "./generated-request-refusal.ts"; import { isFormDispatcher } from "./invocation-identity.ts"; -import type { ComponentInvocation, ComponentRouting } from "./invocation-identity.ts"; -import type { FetchRequest } from "./fetch-request.ts"; -import { normalizeFetchRequest, requestRecord } from "./fetch-request.ts"; -import { JSON_COMPOSITION_IDENTITY, pinnedJson } from "./generated-xmd.ts"; -import type { GeneratedRequest } from "./generated-xmd.ts"; +import type { + ComponentInvocation, + ComponentRouting, + InvocationForm, +} from "./invocation-identity.ts"; +import { pinnedJson } from "./generated-xmd.ts"; import { definitionGeneratedRefusal, offeredForm } from "./components/generated-metadata.ts"; import type { GeneratedEffect } from "./components/generated-metadata.ts"; +import type { AdmittedSelection } from "./generated-selection.ts"; import type { FunctionComponent, FunctionComponentDefinition, Json, PropsSchema } from "./types.ts"; -/** The two forms an element is actually written in. */ -export type FragmentForm = "self-closing" | "paired"; - -/** - * One component a generated fragment may name, as the host states it. - * - * A closed union of the two ways a host can say what is behind a name, and it - * is closed on purpose: a third shape would be a third answer to "where does - * this implementation come from", which is exactly the question an admission - * has to settle once. - * - * Neither arm carries a function. A capability names a component canonical - * core supplies; a component answer names something the ordinary import chain - * resolves, which canonical capture does once — before any document code — and - * then holds by value. What is journaled is the admitted spellings, the - * identity each of them runs under, and the limits. - */ -export type FragmentEntry = CapabilityEntry | ComponentAnswerEntry; - -/** An entry canonical core supplies the component for. */ -export interface CapabilityEntry { - readonly kind: "capability"; - /** The name a fragment writes. */ - readonly name: string; - /** - * The exact spellings this entry is admitted for. - * - * A component whose two spellings do different things is two entries under - * one name — `` reads and `…` writes — and each spelling - * carries its own identity and effect on the declaration that owns the - * implementation, so preflight chooses between them before the first effect. - */ - readonly forms: readonly FragmentForm[]; - /** The contract this entry's props are validated against. */ - readonly props: PropsSchema; - /** - * What this entry does, for the agent being told it may write the name. - * - * The host's, because the entry is: canonical core supplies the operation - * behind a capability, and only the host knows what admitting it under this - * name means here. A narrowed catalog without it would name a component and - * say nothing about it, which is the one thing the catalog exists to avoid. - */ - readonly description?: string; - /** - * Which of core's own components this entry runs. - * - * A name rather than a definition, because the definition is core's to - * supply: capture answers with the ordinary ``, ``, - * ``, ``, `` or `` an author writes, held to the - * execution that captured it. So a host chooses *what a fragment may do* and - * never *how it reaches it*: the operation crosses the contextual provider - * and the consuming scope's `Permissions` exactly as an authored element's - * does (`fragment-capabilities.ts`). - */ - readonly capability: FragmentCapability; - /** - * The exact requests this entry may perform, for an entry that performs any. - * - * Stated per entry rather than per profile, because a ceiling belongs to the - * identity it bounds: two entries performing requests are two ceilings, and - * flattening them would let one entry's limit admit another's request. - */ - readonly requests?: readonly GeneratedRequest[]; -} - -/** - * An entry whose implementation the ordinary import chain answers for. - * - * The host states *what it expects* — the name and the forms — and states no - * implementation at all. There is no definition, function, resolver, provider - * operation, identity or context handle on this arm to hold one. - * - * Canonical capture resolves the name once, before the root import and before - * any document code, through the complete ordinary `Component.importComponent` - * chain; takes the definition the provider published on that exact final - * answer; retains it defensively; and seals the result. Props, the callable - * definition and the identity each admitted form runs under are all derived - * from that answer rather than restated here, so a host cannot describe a - * contract or an effect the implementation does not have. - * - * Resolution happens at capture and never again. A fragment invokes the sealed - * snapshot, and a continuation resolves once more in its own capture and - * reconciles before any effect. Calling a retained resolver later would see - * document-time middleware, which is the whole thing this avoids. - */ -export interface ComponentAnswerEntry { - readonly kind: "component-answer"; - readonly name: string; - readonly forms: readonly FragmentForm[]; - readonly description?: string; -} - -/** - * How a host answers what its Workspace currently retains. - * - * An operation rather than a value, because a run's own progress legitimately - * advances it: every committed mutation retains another immutable root. A - * basis captured once at installation would be stale by the second evaluation - * in one run, so canonical `` asks this per invocation instead. - * - * An ordinary host has none — it evaluates against no Workspace at all, which - * is a different statement from evaluating against an empty one. - */ -export interface FragmentWorkspaceAccess { - snapshot(): Operation<{ readonly roots: readonly string[]; readonly current: string }>; -} - -/** One host's complete statement of what a fragment may do. */ -export interface FragmentEvaluationInput { - /** - * The entries the `read` class resolves to. - * - * For the ordinary profile this is the self-closing ``, the - * self-closing `` and canonical ``; the workflow profile - * states its own. `` joins it only where the host also states the - * exact requests it may perform. - */ - readonly read: readonly FragmentEntry[]; - /** - * The entries the `write` class resolves to, when this host offers the class. - * - * The two standard profiles differ here: the ordinary one is paired - * ``, self-closing `` and paired ``, while the - * workflow one is paired ``, paired `` and self-closing - * ``. Neither includes the read table: `write` selects mutation - * and nothing else. - */ - readonly write?: readonly FragmentEntry[]; - /** How this host answers for its Workspace, when it evaluates against one. */ - readonly workspace?: FragmentWorkspaceAccess; - /** - * The effective Fetch timeout this host resolved, in milliseconds. - * - * Resolved once, by the host, when it builds the profile. Reading it from - * context during preflight would let the ceiling a fragment is held to depend - * on where the reading happened. - */ - readonly fetchTimeout?: number; -} - -/** - * Core's ``, admitted to observe and not to write. - * - * `` reads when it has no content and writes when it has some, so one - * unconstrained entry would admit a write. Each spelling carries its own - * identity and effect on core's own declaration, and preflight decides between - * the two — before the first effect, rather than inside a body that has already - * been entered. - * - * An admitted read is core's ordinary `` performing its ordinary - * operation: it resolves the contextual working directory and reads through - * `API.Files`, under the provider the host installed and the effective - * `Permissions` of the consuming scope. Under a workflow run that provider is - * the transaction-bound one, so the read crosses the run's own transaction for - * the same reason an authored read does. - */ -export function fileReadEntry(): CapabilityEntry { - return coreEntry("File", "file:read"); -} - -/** - * Core's ``, admitted to select paths and nothing else. - * - * A value entry: core's own declaration states what it binds, so a generated - * `` written without `as` is refused before any provider is asked to - * traverse anything. What it selects is bounded the same way a read is — the - * contextual working directory it resolves, and the `API.Files` provider the - * host installed — so the paths it answers with are the ones that provider - * produced for that directory. - */ -export function globReadEntry(): CapabilityEntry { - return coreEntry("Glob", "files:glob"); -} - -/** - * The protected names canonical execution states what is behind, for a profile. - * - * An allowlist rather than "every protected name", because core publishing an - * answer is what makes a protected name admissible: a host that wrote - * `` into its read table would otherwise obtain evaluation inside - * evaluation, which is a different grant from being shown a vocabulary. Core - * answers for the one protected name it offers a host an entry for, and a - * hand-written entry for any other resolves to an answer nobody published and - * is refused. - */ -export const CANONICAL_PROFILE_ANSWERS: ReadonlySet = new Set([SYNTAX_COMPONENT]); - -/** - * Canonical ``, admitted as the answer the import chain gives for it. - * - * Not a capability: `` is canonical core's protected component, and a - * capability arm would be core supplying a second body for a name it already - * owns. So the entry states the name and the one form it has, and canonical - * capture resolves it once through the ordinary import chain — where core - * publishes its own answer for it — and seals that answer through the - * execution's own protected-body route. The identity the fragment is admitted - * under is the one core's declaration states for that form. - * - * Admitting it grants no other permission. The symbols an occurrence renders - * describe the vocabulary the fragment has; naming a component in them is not - * permission to run it, and a component the fragment may only read about stays - * unavailable. - */ -export function syntaxReadEntry(): ComponentAnswerEntry { - return { - kind: "component-answer", - name: SYNTAX_COMPONENT, - forms: ["self-closing"], - description: - "Render the components this evaluation may write, or selected documentation. " + - "Written self-closing.", - }; -} - -/** - * Canonical ``, admitted as a question a generated fragment may ask. - * - * A capability, not a component answer. What core owns here is the *behavior*, - * so the body a fragment reaches is pinned the way ``'s is rather than - * resolved by name: the ordinary import chain answers for the authored element, - * where a repository file, a registration, a declaration and the workflow's own - * suspension replacement all legitimately shadow it, and none of them is what a - * generated fragment was admitted to run. - * - * Resolving it was also the wrong *time*. An admitted component answer is - * resolved for every execution the profile belongs to, before any document code - * — so a name a host had quite properly shadowed made executions that never ask - * anything refuse, and a document holding only `` was refused for an - * entry it never wrote. Nothing is resolved now: preflight selects this entry - * only where it finds an `` occurrence, and what it selects is core's. - * - * In the write table and not the read one. Asking a person is not reading: - * `allow={["read"]}` admits what a fragment may look at without anyone being - * interrupted, and a question is an interaction whose answer then drives what - * the fragment writes. A program a person is asked to approve before it writes - * is the case this exists for, and a `read` fragment that could ask would be - * able to interrupt on a permission that promised it would not. - * - * Paired only. The question is the content, so a self-closing spelling would be - * a request with nothing in it. - */ -export function elicitWriteEntry(): CapabilityEntry { - return coreEntry(ELICIT_COMPONENT, "elicit:ask"); -} - -/** Core's `…`, admitted to write and not to read. */ -export function fileWriteEntry(): CapabilityEntry { - return coreEntry("File", "file:write"); -} - -/** - * Core's ``, in the one form it has. - * - * One name, one form: unlike ``, whose two spellings do different things, - * this answers the self-closing form alone. Stating the form is what puts the - * decision in preflight, before the fragment's first effect — a paired spelling - * costs an earlier admitted element nothing. - */ -export function fileDeleteEntry(): CapabilityEntry { - return coreEntry("File.Delete", "file:delete"); -} - -/** - * A directory an admitted fragment may create, under the name the host gives it. - * - * The name is the host's because the component is: the workflow calls it - * ``, and an ordinary run admits no such thing. What core supplies is the - * component — its implementation, and the identity and effect its paired form - * is offered under — so a host chooses whether the name is admitted here and - * never what admitting it grants. - */ -export function directoryEntry(name: string): CapabilityEntry { - return { - kind: "capability", - name, - forms: [...CAPABILITY_FORMS["directory:ensure"]], - capability: "directory:ensure", - props: capabilityProps("directory:ensure"), - description: CORE_DESCRIPTIONS["directory:ensure"], - }; -} - -/** - * Core's ``, bounded to exactly these requests. - * - * The ceiling is not optional and this constructor does not decide it: an - * unbounded network read is a different grant from an admitted one, and a host - * that states no request admits `` not at all rather than admitting it - * and refusing everything it asks for. - */ -export function fetchEntry(requests: readonly GeneratedRequest[]): CapabilityEntry { - return { ...coreEntry("Fetch", "fetch"), requests }; -} - -/** What core's own entries tell an agent they do. */ -const CORE_DESCRIPTIONS: Readonly> = Object.freeze({ - "elicit:ask": - "Ask a person a structured question and bind the validated answer. Written paired, with " + - "the request as its content.", - "file:read": "Read one file and render its text. Written self-closing.", - "files:glob": - "Select the files under the working directory that these patterns match, as a sorted " + - "list of relative paths. Written self-closing and captured with `as`.", - "file:write": "Write what it renders to one file. Written with content.", - "file:delete": "Remove one file. Written self-closing.", - "directory:ensure": - "Make one directory exist, and resolve the paths inside it against it. Written with content.", - fetch: "Perform one admitted HTTP read. Written self-closing.", -}); - -function coreEntry(name: string, capability: FragmentCapability): CapabilityEntry { - return { - kind: "capability", - name, - forms: [...CAPABILITY_FORMS[capability]], - capability, - props: capabilityProps(capability), - description: CORE_DESCRIPTIONS[capability], - }; -} - /** * One provider-backed name, already resolved and reconciled. * - * Produced by canonical execution's capture step before this module is asked - * to seal anything: the chain has answered, the definition the provider - * published has been taken off that exact final object, and it has been - * retained defensively. What arrives here is the settled result, so nothing in - * this module resolves, looks up, or holds a resolver it could call later. + * Produced by canonical execution's capture step before this module is asked to + * capture anything: the chain has answered, the definition published on that + * exact final answer has been taken off it, and it has been retained + * defensively. What arrives here is the settled result, so nothing in this + * module resolves, looks up, or holds a resolver it could call later. */ export interface ResolvedAnswer { /** Core's own retained copy, which is what a fragment invokes. */ readonly definition: FunctionComponentDefinition; - /** Which provider installation published it, as documentation provenance. */ + /** Which installation published it, as documentation provenance. */ readonly origin: string; } -/** Every provider-backed name this capture resolved, by name. */ +/** Every selected name this capture resolved, by name. */ export type ResolvedAnswers = ReadonlyMap; /** * One captured entry: frozen structural data beside one sealed implementation. * - * `kind` survives capture because the retained record keeps it: a continuation - * comparing identities has to know whether the thing behind a name was core's - * own operation or a provider's answer, and the two are different grants. - * * There is no identity on it. Which URL and effect each admitted form runs * under is on the definition this entry already holds, declared by whoever owns * the implementation, so a capture that restated them would be a second source @@ -424,43 +92,46 @@ export interface CapturedEntry { * Where this entry's implementation came from, for a reader and for the * admitted vocabulary's symbols. * - * Core's own origin for an operation core supplies the body for, and the - * publishing installation's for a provider's answer. Documentation - * provenance: it is no part of what the implementation is, and nothing - * compares a continuation against it. + * The publishing installation's origin, which is core's own wherever + * canonical resolution answered with core's. Documentation provenance: it is + * no part of what the implementation is, and nothing compares a continuation + * against it. */ readonly origin: string; - readonly forms: readonly FragmentForm[]; + readonly forms: readonly InvocationForm[]; readonly props: PropsSchema; - readonly kind: "capability" | "component-answer" | "composition"; - /** Which operation this runs, for a capability entry. */ - readonly capability?: FragmentCapability; - /** What the admitted vocabulary says this entry does, when the host said. */ + /** + * Which table holds this entry. + * + * Every selected name is a `component-answer`: its implementation came + * through the complete ordinary import chain and the canonical terminal + * behind it, whether that answered with core's own fixed component, a + * registration or a provider's published definition. `composition` is the + * always-on trusted table, which `allow` does not select between. + */ + readonly kind: "component-answer" | "composition"; + /** What the owning declaration says this entry does, when it says anything. */ readonly description?: string; /** * What a fragment invokes. * - * For a capability, core's own body closed over the operations this capture - * bound. For a component answer, core's retained copy of the exact final - * answer the import chain gave — never the object the chain was holding, and - * never something re-resolved later. + * Core's retained copy of the exact final answer the import chain gave, held + * behind this execution's lifetime — never the object the chain was holding, + * and never something re-resolved later. */ readonly definition: FunctionComponentDefinition; /** * The form dispatcher underneath this entry's implementation, when the sealed * definition wraps one. * - * A component answer runs behind a lifetime guard canonical capture built, so - * the function on the definition is core's rather than the provider's. A - * provider whose own answer dispatches on the authored form would otherwise + * A selected name runs behind a lifetime guard canonical capture built, so + * the function on the definition is core's rather than the owner's. A + * component whose own answer dispatches on the authored form would otherwise * lose that dispatch, because what a selection records as the form dispatcher * is read off the definition it was handed. So the dispatcher travels - * explicitly, and a capability — whose definition is core's own dispatcher — - * states none. + * explicitly. */ readonly dispatch?: unknown; - /** This entry's own ceiling, normalized once and canonically ordered. */ - readonly requests?: readonly FetchRequest[]; } /** What canonical execution keeps, and what canonical `` reads. */ @@ -468,7 +139,7 @@ export interface EvaluationProfile { /** * The pure components every evaluation may write, whatever `allow` selects. * - * Core's own, and not a table a host states: composition carries no effect + * Core's own, and not something a host selects: composition carries no effect * operation, so admitting it grants nothing and omitting it would take a * language construct away rather than a permission. `allow` selects between * the two effect tables below and never between these. @@ -476,7 +147,6 @@ export interface EvaluationProfile { readonly composition: readonly CapturedEntry[]; readonly read: readonly CapturedEntry[]; readonly write: readonly CapturedEntry[]; - readonly workspace?: FragmentWorkspaceAccess; /** Whether this profile's admitted implementations are still usable. */ readonly live: () => boolean; /** @@ -490,176 +160,197 @@ export interface EvaluationProfile { readonly revoke: () => void; } -/** What an execution that was offered no evaluation profile refuses with. */ +/** What an execution whose host selected no generated components refuses with. */ export const NO_PROFILE = - " has no evaluation profile here: this host stated none, so nothing established " + - "what a generated fragment may do. A profile is the host's own statement of the maximum " + - "permission an evaluation has, and `allow` only narrows it."; - -/** What an execution offered two evaluation profiles refuses with. */ -export const TWO_PROFILES = - "two installations stated the evaluation profile this execution offers. One execution offers " + - "one maximum permission, so what a generated fragment may do is never a question of assembly " + - "order."; + " has no generated components here: this host selected none, so nothing " + + "established what a generated fragment may do. The selection is the host's own statement of " + + "the maximum permission an evaluation has, and `allow` only narrows it."; /** What a profile whose execution has ended refuses with. */ export const REVOKED = - "the evaluation profile this fragment was admitted under belongs to an execution that has " + + "the generated components this fragment was admitted under belong to an execution that has " + "ended."; -/** A profile a host stated in a shape canonical execution cannot capture. */ +/** A selected name canonical execution cannot capture an implementation for. */ export class EvaluationProfileError extends Error { override name = "EvaluationProfileError"; } /** - * One entry this execution has copied, before it knows what is behind a - * provider-backed name. - * - * Everything a host stated is already settled here — the forms, the schema for - * a capability, the ceiling, and the effect table this entry came from. What is - * missing is exactly the part a host does not state: a component answer's - * implementation, the contract that comes with it, and the identity its own - * declaration offers each admitted form under. + * The lifetime every captured implementation is held to. + * + * Opened by canonical execution and armed before a single name is resolved, so + * a captured operation cannot escape an execution whose revocation is not yet + * registered. There is nothing else on it: a selected name runs the + * implementation canonical resolution answered with, through the provider + * installed where the fragment runs, so there is no host operation to bind and + * revoke beside it. */ -interface PreparedEntry { - readonly name: string; - readonly forms: readonly FragmentForm[]; - readonly kind: "capability" | "component-answer" | "composition"; - /** Which table admitted it, so a form's offered effect is checked against it. */ - readonly effect: GeneratedEffect; - readonly description?: string; - readonly props?: PropsSchema; - readonly capability?: FragmentCapability; - readonly definition?: FunctionComponentDefinition; - /** The owning component's own form dispatcher, when its implementation wraps one. */ - readonly dispatch?: unknown; - readonly requests?: readonly FetchRequest[]; +export interface EvaluationLifetime { + live(): boolean; + revoke(): void; } -/** - * One host's profile, copied and bound, waiting only for its provider answers. - * - * Preparation is where a host stops being consulted: the tables are copied, the - * identities and forms are frozen, the ceilings are normalized, and every live - * operation is read off the host's objects once and bound behind this - * execution's revocation. A host that mutates its own arrays, headers or tables - * afterwards changes nothing. - * - * What preparation cannot do is decide what a provider-backed name resolves to: - * that takes the complete ordinary import chain, which does not exist until the - * providers have installed. So it publishes the names that need resolving and - * seals afterwards. - */ -export interface PreparedProfile { - /** - * The provider-backed names this profile admits. - * - * Names alone, because what is behind one is the definition the chain - * answers with: a host states which names it admits and the owning - * declaration states what each admitted form runs as. Empty for a - * capability-only profile, which performs no component-chain lookup at all — - * a host that offers no provider answer pays for none. - */ - readonly answered: readonly string[]; - /** Whether the operations this preparation bound are still usable. */ - readonly live: () => boolean; - /** End them. Registered by canonical execution before any installation runs. */ - readonly revoke: () => void; - /** Seal answers, projecting lifetime wrappers through the execution's private operation. */ - seal( - answers: ResolvedAnswers, - project?: ComponentRouting["project"], - ): Operation; +/** Open one. */ +export function evaluationLifetime(): EvaluationLifetime { + let active = true; + return { + live: () => active, + revoke: () => { + active = false; + }, + }; } /** - * Copy and bind one host's profile, before any installation runs. - * - * Everything structural is copied and frozen here, so a host mutating its own - * arrays, schemas, headers or tables afterwards changes nothing this execution - * does. What is not copied is the implementation itself: a function is the one - * thing a profile must keep by reference, and it is kept behind a revocation - * this execution owns rather than handed onward. + * Capture what this host selected, once, after every selected name has been + * resolved and before public execution policy. + * + * Nothing is consulted here that a host still holds. The selection arrived as + * owned, validated data; each definition arrived as core's own retained copy of + * an answer canonical resolution produced; and everything structural this + * module derives — the props contract, the description, the admitted forms and + * which table each of them lands in — is read off that definition rather than + * restated. */ -export function* prepareEvaluationProfile( - input: FragmentEvaluationInput, -): Operation { - // The lifetime every admitted implementation is held to, opened here before a - // single installation has run. There is no host operation to read off an - // object any more: an admitted name runs canonical core's own component, - // through the provider installed where the fragment runs. - const capabilities = captureCapabilities(); - // One definition per *name*, built from every capability that name holds - // across both tables. `` observing and `…` writing are - // two entries under one name, and the evaluator resolves an import by name — - // so they arrive as one definition whose dispatch separates the two - // spellings, exactly as the ordinary `` does. - const built = buildDefinitions(input, capabilities); - const read = yield* prepareEntries(input.read ?? [], "read", input.fetchTimeout, built); - const write = yield* prepareEntries(input.write ?? [], "write", input.fetchTimeout, built); - if (read.length === 0 && write.length === 0) { - throw new EvaluationProfileError( - "an evaluation profile states no component at all. A host offering evaluation states what " + - "a fragment may do; one that offers none states no profile.", - ); - } - // One lookup per distinct name, however many entries and tables hold it: a - // name resolves to one implementation, and asking twice would be two chances - // for the chain to answer differently. - // Core's own, built before the host's tables are examined, so a host naming - // one of these refuses at capture rather than shadowing it at evaluation. +export function captureEvaluationProfile( + selection: AdmittedSelection, + answers: ResolvedAnswers, + lifetime: EvaluationLifetime, + project?: ComponentRouting["project"], +): EvaluationProfile { + // Core's own, built before the selection is examined, so a host selecting one + // of these refuses at capture rather than shadowing it at evaluation. const composition = compositionEntries(); const reserved = new Set(composition.map((entry) => entry.name)); - for (const entry of [...read, ...write]) { - if (reserved.has(entry.name)) { + const read: CapturedEntry[] = []; + const write: CapturedEntry[] = []; + for (const [name, forms] of selection) { + if (reserved.has(name)) { throw new EvaluationProfileError( - `an evaluation profile admitted "${entry.name}" as an effect, and it is trusted ` + - "composition every evaluation already has. One name is one component, and a table " + - "cannot give an effect to a name that performs none.", + `a host selected "${name}" as a generated effect, and it is trusted composition every ` + + "evaluation already has. One name is one component, and a selection cannot give an " + + "effect to a name that performs none.", ); } + const captured = captureName(name, forms, answers, lifetime, project); + read.push(...captured.read); + write.push(...captured.write); } - const answered = answeredNames([...read, ...write]); - const workspace = input.workspace === undefined ? undefined : bindWorkspace(input.workspace); return Object.freeze({ - answered: Object.freeze([...answered]), - live: () => capabilities.live(), + composition, + read: Object.freeze(read), + write: Object.freeze(write), + live: () => lifetime.live(), revoke: () => { - capabilities.revoke(); - }, - // deno-lint-ignore require-yield - *seal( - answers: ResolvedAnswers, - project?: ComponentRouting["project"], - ): Operation { - // One sealed implementation per name, built before either table is - // sealed. A name that holds two entries — the self-closing spelling in - // `read` and the paired one in `write` — is one component seen from two - // sides, so both entries carry the same object: two guards over one - // answer would be two lifetimes for one implementation, and which of - // them a fragment reached would depend on which table admitted it. - const sealed = sealAnswers(answered, answers, capabilities, project); - return Object.freeze({ - composition, - read: sealEntries(read, sealed), - write: sealEntries(write, sealed), - ...(workspace === undefined ? {} : { workspace }), - live: () => capabilities.live(), - revoke: () => { - capabilities.revoke(); - }, - }); + lifetime.revoke(); }, }); } +/** The entries one selected name contributes, by the effect its owner offers. */ +interface CapturedName { + readonly read: readonly CapturedEntry[]; + readonly write: readonly CapturedEntry[]; +} + +/** + * One selected name, as one implementation seen from the tables its spellings + * belong to. + * + * A name resolves once and runs one implementation, however many spellings it + * was selected for: `` observing and `…` writing are two + * entries under one name, and both carry the same sealed object. Two guards + * over one answer would be two lifetimes for one implementation, and which of + * them a fragment reached would depend on which table admitted it. + * + * Which table each spelling lands in is the owner's answer rather than the + * selection's. A host selects a spelling; its declaration says whether that + * spelling reads or writes, and a spelling the declaration offers no generated + * use of refuses here — before an execution holds a profile at all. + */ +function captureName( + name: string, + forms: readonly InvocationForm[], + answers: ResolvedAnswers, + lifetime: EvaluationLifetime, + project: ComponentRouting["project"] | undefined, +): CapturedName { + const resolved = answers.get(name); + if (resolved === undefined) { + throw new EvaluationProfileError( + `a host selected "${name}" for generated use, and this execution resolved no ` + + "implementation for it.", + ); + } + const answer = resolved.definition; + // What the definition offers at all, before which of it this selection + // admits. A provider's answer was read where it was published and core's own + // owners where they are declared, so a refusal here is a definition that + // reached a capture without crossing either. + const refusal = definitionGeneratedRefusal(answer); + if (refusal !== undefined) { + throw new EvaluationProfileError( + `a host selected "${name}" for generated use, whose declaration ${refusal}.`, + ); + } + // Which class each selected spelling reaches, from the owner's own metadata. + // It decides the table below and words the refusal the guard reports. + const effects: Partial> = {}; + const byEffect = new Map(); + for (const form of forms) { + const offered = offeredForm(answer.generated, form); + if (offered === undefined) { + throw new EvaluationProfileError( + `a host selected "${name}" in its ${form} form, and the declaration that owns that ` + + "implementation offers no generated use of it. A generated fragment runs a form its " + + "owner states an identity and an effect for.", + ); + } + effects[form] = offered.effect; + const held = byEffect.get(offered.effect) ?? []; + held.push(form); + byEffect.set(offered.effect, held); + } + const inner = answer.fn; + const guard = bounded(name, inner, lifetime, Object.freeze({ ...effects })); + const protectedOrigin = project?.(inner, guard); + const shared = { + name, + origin: resolved.origin, + props: detach(answer.props), + kind: "component-answer" as const, + definition: Object.freeze({ ...answer, fn: guard }), + ...(protectedOrigin === undefined ? {} : { protectedOrigin }), + ...(typeof answer.description === "string" ? { description: answer.description } : {}), + // The owner's own dispatcher, when its answer has one. The definition above + // runs behind core's lifetime guard, so what a selection would read off it + // is core's function rather than the owner's — and a form dispatcher read + // off the wrong function selects no body at all. + ...(isFormDispatcher(inner) ? { dispatch: inner } : {}), + }; + return { + read: entriesFor(shared, byEffect.get("read")), + write: entriesFor(shared, byEffect.get("write")), + }; +} + +function entriesFor( + shared: Omit, + forms: readonly InvocationForm[] | undefined, +): readonly CapturedEntry[] { + if (forms === undefined) { + return []; + } + return [Object.freeze({ ...shared, forms: canonicalForms(forms) })]; +} + /** * The trusted composition table, as this execution retains it. * * Built from the same pinned core entry the evaluator admits, so the vocabulary * an agent is shown and the vocabulary preflight enforces are one fact rather - * than two copies of one. Nothing here is a host's to state: a composition + * than two copies of one. Nothing here is a host's to select: a composition * entry runs a component that performs nothing, so there is no operation to * bind and no revocation to hold. */ @@ -669,7 +360,7 @@ function compositionEntries(): readonly CapturedEntry[] { Object.freeze({ name: entry.name, origin: CORE_ORIGIN, - forms: Object.freeze(["self-closing"]), + forms: Object.freeze(["self-closing"]), props: detach(entry.definition.props), kind: "composition" as const, definition: entry.definition, @@ -682,457 +373,142 @@ function compositionEntries(): readonly CapturedEntry[] { } /** - * The workspace access, read once and bound. - * - * Bound rather than kept as the host's object, so replacing the method on that - * object after installation reaches nothing. - */ -function bindWorkspace(access: FragmentWorkspaceAccess): FragmentWorkspaceAccess { - const snapshot = access.snapshot.bind(access); - return Object.freeze({ - *snapshot() { - const answered = yield* snapshot(); - // Copied on the way out too: what the host answered with is its own - // object, and the basis this invocation is admitted under must not move - // afterwards. - return Object.freeze({ - roots: Object.freeze([...answered.roots]), - current: answered.current, - }); - }, - }); -} - -/** - * One definition per admitted name, built before any entry is captured. - * - * Built here rather than per entry because a name's definition depends on - * *every* capability that name holds: a profile admitting `` to read - * and `…` to write is describing one component with two spellings, - * and which body a spelling reaches is dispatch's decision rather than the - * table's. - * - * A ceiling belongs to the entry that states it, so the requests handed to a - * name's definition are that name's own — never a flattened set, which would - * let one entry's limit admit another's request. - */ -function buildDefinitions( - input: FragmentEvaluationInput, - capabilities: CapturedCapabilities, -): Map { - const admitted = new Map< - string, - { "self-closing"?: FragmentCapability; paired?: FragmentCapability } - >(); - const ceilings = new Map(); - for (const entry of [...(input.read ?? []), ...(input.write ?? [])]) { - // A component answer has no capability behind it: canonical capture - // resolves its implementation through the ordinary import chain and derives - // the definition from that answer, so there is nothing for this to build. - if (entry.kind !== "capability") { - continue; - } - const held = admitted.get(entry.name) ?? {}; - for (const form of CAPABILITY_FORMS[entry.capability]) { - held[form] = entry.capability; - } - admitted.set(entry.name, held); - if (entry.requests !== undefined) { - ceilings.set(entry.name, entry.requests); - } - } - const built = new Map(); - for (const [name, forms] of admitted) { - built.set( - name, - capabilityImplementation( - name, - forms, - capabilities, - normalizedCeiling(ceilings.get(name), input), - ), - ); - } - return built; -} - -/** - * One name's ceiling, normalized on the same terms the retained policy is. - * - * The component performs the admitted request rather than the props, so the - * value it holds has to be the one preflight compared against — normalized - * once, against the host's own resolved timeout, rather than read again from a - * context wherever the performance happens. + * One captured implementation, held to this execution's lifetime and reporting + * a refusal of the fragment's own request as one. + * + * Two jobs, and both of them are the capture's rather than the component's. + * + * The lifetime is the execution's: a selected name's body reaches whatever its + * owner closed over, which this execution never saw and cannot revoke — so the + * *entry* carries the lifetime instead, and a definition somebody kept refuses + * rather than running against a run that is over. + * + * The classification is the caller's. A host driving a loop needs to know + * whether the fragment's own text was refused, and only the code that raised + * the failure knows that — so an ordinary refusal from an admitted component is + * marked here, with a safe reason, and everything else is left unmarked. + * + * The guard is what capture builds the entry's definition around, not a wrapper + * placed over one afterwards: this function is the implementation the entry has + * ever had, so nothing about which body a form selects moves. It opens no scope + * and selects no body, so the component's own dispatcher still runs in the + * frame the engine issued it for. */ -function normalizedCeiling( - requests: readonly GeneratedRequest[] | undefined, - input: FragmentEvaluationInput, -): readonly FetchRequest[] { - if (requests === undefined) { - return []; - } - return requests.map((request) => normalizeFetchRequest({ ...request }, input.fetchTimeout)); -} - -function* prepareEntries( - entries: readonly FragmentEntry[], - effect: GeneratedEffect, - fetchTimeout: number | undefined, - built: Map, -): Operation { - const prepared: PreparedEntry[] = []; - for (const entry of entries) { - prepared.push(yield* prepareEntry(entry, effect, fetchTimeout, built)); - } - return Object.freeze(prepared); -} - -function* prepareEntry( - entry: FragmentEntry, - effect: GeneratedEffect, - fetchTimeout: number | undefined, - built: Map, -): Operation { - const forms = canonicalForms(entry.forms); - if (forms.length === 0) { - throw new EvaluationProfileError( - `an evaluation profile admitted "${entry.name}" for no authored form.`, - ); - } - const described = - typeof entry.description === "string" && entry.description.length > 0 - ? { description: entry.description } - : {}; - - if (entry.kind === "component-answer") { - // Nothing about the implementation is settled here. What this entry states - // is which name and which forms; the chain answers later, and the props, - // the identity and the effect all come from that answer rather than from - // the host, so a host cannot describe a contract or an effect the - // implementation lacks. - return Object.freeze({ - name: entry.name, - forms, - kind: "component-answer" as const, - effect, - ...described, - }); - } - - const requests = - entry.requests === undefined - ? undefined - : yield* captureRequests(entry.requests, fetchTimeout, entry.name); - // Detached, so a host that edits its schema afterwards does not change what a - // fragment's props are validated against. - const props = detach(entry.props); - // The implementation every entry under this name shares, built before any - // entry was prepared. - const implementation = built.get(entry.name); - if (implementation === undefined) { +function bounded( + name: string, + inner: FunctionComponentDefinition["fn"], + lifetime: EvaluationLifetime, + effects: Readonly>>, +): FunctionComponent { + if (typeof inner !== "function") { throw new EvaluationProfileError( - "an evaluation profile admitted a name with no operation behind it.", + `a host selected "${name}" for generated use, and the import chain answered with a ` + + "definition that has no implementation to invoke. A selected name runs something.", ); } - const prepared: PreparedEntry = Object.freeze({ - name: entry.name, - forms, - props, - kind: "capability" as const, - effect, - capability: entry.capability, - ...described, - definition: implementation.definition, - ...(implementation.dispatch === undefined ? {} : { dispatch: implementation.dispatch }), - ...(requests === undefined ? {} : { requests }), - }); - // Core supplies this body, so the owning declaration is already in hand: a - // capability admitted in the table its owner does not offer that form to - // refuses here, before an execution holds a profile at all. - admittedIdentities(prepared, implementation.definition); - return prepared; -} - -/** - * The provider-backed names this profile admits. - * - * A name is one component, so it resolves once and runs one implementation. Two - * entries under one name are the two spellings of that component — the - * self-closing one admitted to observe and the paired one to mutate — and both - * read their own identity and effect off the one definition the chain answered - * with, so there is nothing for a second entry to disagree about. - * - * A name a host holds as both a capability and a component answer is a real - * ambiguity, and refuses: canonical core would supply one body and the import - * chain the other, which are different grants under one spelling. - */ -function answeredNames(prepared: readonly PreparedEntry[]): ReadonlySet { - const answered = new Set(); - const kinds = new Map(); - for (const entry of prepared) { - const held = kinds.get(entry.name); - if (held !== undefined && held !== entry.kind) { - throw new EvaluationProfileError( - `an evaluation profile admitted "${entry.name}" both as an operation core supplies the ` + - "body for and as an implementation the import chain answers for. One name is one " + - "component, and those are different grants.", - ); + const implementation = inner; + return function* held( + props: Record, + invocation: ComponentInvocation, + ): Operation { + if (!lifetime.live()) { + throw new EvaluationProfileError(REVOKED); } - kinds.set(entry.name, entry.kind); - if (entry.kind === "component-answer") { - answered.add(entry.name); + try { + return yield* implementation(props, invocation); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + const reason = refusedReason(effects, props, invocation, error); + if (reason === undefined) { + throw error; + } + throw markGeneratedRequestRefusal(error, reason); } - } - return answered; + }; } -/** One provider-backed name's sealed implementation, shared by every entry. */ -interface SealedAnswer { - readonly protectedOrigin?: string; - readonly origin: string; - readonly props: PropsSchema; - readonly definition: FunctionComponentDefinition; - readonly dispatch?: unknown; -} +/** The verb a refusal of each filesystem effect is reported with. */ +const VERBS: Readonly> = Object.freeze({ + read: "read", + write: "write", +}); /** - * One sealed implementation per provider-backed name, built once per capture. - * - * Built here rather than per entry because a name has one implementation: the - * lifetime guard, the detached schema and the form dispatcher are properties of - * that implementation, and building them twice would give one component two - * bodies whose only difference was which table asked for it. + * The safe reason this failure carries, when it is a refusal of the request. + * + * Core's own fixed sentence, not the component's. The component's message is + * safe to *print* — it names the path the document wrote and a phrase from a + * fixed vocabulary — but a write that failed because its content failed to + * expand reports what that content said, and the content of a generated + * fragment is text nobody audited. A refusal reason travels back to whatever is + * driving the loop, so it is built here from the effect the spelling reaches + * and the authored path, and from nothing the fragment produced. + * + * The glob sentences are the exception and are already written for this: a + * pattern a fragment wrote wrongly is reported by `glob-source.ts` in the same + * words an author would be told, and no provider diagnostic reaches them. + * + * Everything else is unmarked, which is the safe default. An absent provider, a + * broken provider contract, a revoked execution, a cancellation and a durable + * failure are not the request being refused, and calling any of them one would + * invite another turn at work that cannot succeed. */ -function sealAnswers( - answered: ReadonlySet, - answers: ResolvedAnswers, - capabilities: CapturedCapabilities, - project: ComponentRouting["project"] | undefined, -): ReadonlyMap { - const sealed = new Map(); - for (const name of answered) { - // Resolved once, before any document code, through the complete ordinary - // import chain, and published there by the provider that supplied it. - const resolved = answers.get(name); - if (resolved === undefined) { - throw new EvaluationProfileError( - `an evaluation profile admitted "${name}" as a component answer, and this execution ` + - "resolved none for it.", - ); - } - const answer = resolved.definition; - const inner = answer.fn; - const guard = bounded(inner, capabilities); - const protectedOrigin = project?.(inner, guard); - sealed.set( - name, - Object.freeze({ - origin: resolved.origin, - props: detach(answer.props), - definition: Object.freeze({ ...answer, fn: guard }), - ...(protectedOrigin === undefined ? {} : { protectedOrigin }), - // The provider's own dispatcher, when its answer has one. The - // definition above runs behind core's lifetime guard, so what a - // selection would read off it is core's function rather than the - // provider's — and a form dispatcher read off the wrong function selects - // no body at all. - ...(isFormDispatcher(inner) ? { dispatch: inner } : {}), - }), - ); +function refusedReason( + effects: Readonly>>, + props: Record, + invocation: ComponentInvocation, + error: unknown, +): string | undefined { + if (error instanceof GlobError) { + return error.message; } - return sealed; -} - -function sealEntries( - prepared: readonly PreparedEntry[], - sealed: ReadonlyMap, -): readonly CapturedEntry[] { - return Object.freeze(prepared.map((entry) => sealEntry(entry, sealed))); -} - -function sealEntry(entry: PreparedEntry, sealed: ReadonlyMap): CapturedEntry { - const described = entry.description === undefined ? {} : { description: entry.description }; - if (entry.kind === "component-answer") { - const answer = sealed.get(entry.name); - if (answer === undefined) { - throw new EvaluationProfileError( - `an evaluation profile admitted "${entry.name}" as a component answer, and this ` + - "execution resolved none for it.", - ); - } - // The identity each admitted form runs under is on this definition, so it - // is checked here rather than at the first generated effect: a form the - // provider's own declaration never offered, or offered under the other - // effect, is a table this host cannot state. - admittedIdentities(entry, answer.definition); - return Object.freeze({ - name: entry.name, - origin: answer.origin, - forms: entry.forms, - props: answer.props, - kind: "component-answer" as const, - ...described, - definition: answer.definition, - ...(answer.protectedOrigin === undefined ? {} : { protectedOrigin: answer.protectedOrigin }), - ...(answer.dispatch === undefined ? {} : { dispatch: answer.dispatch }), - }); + if (error instanceof FileDeleteError) { + return refused("delete", props); } - const definition = entry.definition; - const props = entry.props; - if (definition === undefined || props === undefined) { - throw new EvaluationProfileError( - "an evaluation profile admitted a name with no operation behind it.", - ); + if (error instanceof DirInvocationError) { + return refused("create", props); } - return Object.freeze({ - name: entry.name, - origin: CORE_ORIGIN, - forms: entry.forms, - props, - kind: "capability" as const, - ...(entry.capability === undefined ? {} : { capability: entry.capability }), - ...described, - definition, - ...(entry.dispatch === undefined ? {} : { dispatch: entry.dispatch }), - ...(entry.requests === undefined ? {} : { requests: entry.requests }), - }); + if (!(error instanceof FileAccessError)) { + return undefined; + } + const effect = effects[writtenForm(invocation) ?? "self-closing"]; + return effect === undefined ? undefined : refused(VERBS[effect], props); } -/** - * Hold each admitted form to the identity its owner offers it under. - * - * Two refusals, and both of them are about the table rather than about the - * fragment: a form the owning declaration offers no generated use of has no - * identity for a continuation to be compared against, and a form offered as a - * read cannot be admitted in the write table — the effect belongs to the - * spelling, so a host placing it elsewhere would be granting what the - * implementation does not do. - * - * It keeps nothing. What a run retains is derived from the same definition - * where the admission is recorded (`generated-xmd.ts`), so this is the early - * refusal rather than a second copy of the fact. - */ -function admittedIdentities(entry: PreparedEntry, definition: FunctionComponentDefinition): void { - // What the definition offers at all, before which of it this entry admits. A - // provider's answer was read where it was published and core's own owners - // where they are declared, so a refusal here is a definition that reached a - // profile without crossing either. - const refusal = definitionGeneratedRefusal(definition); - if (refusal !== undefined) { - throw new EvaluationProfileError( - `an evaluation profile admitted "${entry.name}", whose declaration ${refusal}.`, - ); - } - for (const form of entry.forms) { - const offered = offeredForm(definition.generated, form); - if (offered === undefined) { - throw new EvaluationProfileError( - `an evaluation profile admitted "${entry.name}" in its ${form} form, and the ` + - "declaration that owns that implementation offers no generated use of it. A " + - "generated fragment runs a form its owner states an identity and an effect for.", - ); - } - if (offered.effect !== entry.effect) { - throw new EvaluationProfileError( - `an evaluation profile admitted "${entry.name}" in its ${form} form as a ` + - `${entry.effect}, and its owner offers that form as a ${offered.effect}. What a ` + - "spelling does belongs to the component, not to the table that admits it.", - ); - } - } +function refused(verb: string, props: Record): string { + return `an admitted fragment could not ${verb} ${JSON.stringify(String(props.path))}.`; } /** - * One provider's implementation, held to this execution's lifetime. - * - * A capability reaches the host's operations through bindings this capture - * already revokes, so a capability body retained past teardown refuses on its - * own. A provider's answer reaches whatever the provider closed over, which this - * execution never saw and cannot revoke — so the *entry* carries the lifetime - * instead, and a definition somebody kept refuses rather than running against a - * run that is over. - * - * The guard is what canonical capture builds the entry's definition around, not - * a wrapper placed over one afterwards: this function is the implementation the - * entry has ever had, so nothing about which body a form selects moves. + * Which spelling this element was written as. + * + * Read from the authored shape, which is the only thing that separates + * `` from `…` once both are selected under one name. This + * is **not** how the effect was selected: the component's own dispatcher + * already did that, from the scan, before its body ran — and the failure this + * words a sentence for has already happened. A forged invocation can therefore + * change which verb a refusal is phrased with and nothing else, which is why + * reading it here is observation rather than authentication. */ -function bounded( - inner: FunctionComponentDefinition["fn"], - capabilities: CapturedCapabilities, -): FunctionComponent { - if (typeof inner !== "function") { - throw new EvaluationProfileError( - "an evaluation profile admitted a component answer with no invocable implementation.", - ); +function writtenForm(invocation: ComponentInvocation): InvocationForm | undefined { + try { + return invocation.hasContent() ? "paired" : "self-closing"; + } catch { + return undefined; } - const implementation = inner; - return function* held( - props: Record, - invocation: ComponentInvocation, - ): Operation { - if (!capabilities.live()) { - throw new FragmentCapabilityError(REVOKED_CAPABILITY); - } - return yield* implementation(props, invocation); - }; } /** The forms an entry is admitted for: a non-empty set in canonical order. */ -const FORMS: readonly FragmentForm[] = ["self-closing", "paired"]; +const FORMS: readonly InvocationForm[] = ["self-closing", "paired"]; -function canonicalForms(forms: readonly FragmentForm[]): readonly FragmentForm[] { +function canonicalForms(forms: readonly InvocationForm[]): readonly InvocationForm[] { const held = new Set(forms); return Object.freeze(FORMS.filter((form) => held.has(form))); } -/** - * One entry's ceiling, normalized once and canonically ordered. - * - * Normalized here rather than at preflight so the comparison a continuation - * makes is one normalization rather than two readings that could disagree, and - * ordered so two hosts stating the same requests in different orders state the - * same ceiling. The timeout is the host's resolved value: reading it from - * context later would make the ceiling depend on where it was read. - */ -function* captureRequests( - requests: readonly GeneratedRequest[], - fetchTimeout: number | undefined, - name: string, -): Operation { - if (requests.length === 0) { - throw new EvaluationProfileError( - `an evaluation profile admitted "${name}" as a request entry with no request it may perform.`, - ); - } - const normalized: FetchRequest[] = []; - for (const request of requests) { - // Normalized against the host's own resolved bound rather than a context - // read: a ceiling compared across a suspension cannot depend on where the - // comparison happened. - const prepared = normalizeFetchRequest({ ...request }, fetchTimeout); - normalized.push( - Object.freeze({ - url: prepared.url, - method: prepared.method, - headers: Object.freeze({ ...prepared.headers }), - ...(prepared.timeout === undefined ? {} : { timeout: prepared.timeout }), - }), - ); - } - // Deduplicated and ordered by the record a continuation compares, so one - // ceiling stated twice is one ceiling. - const seen = new Map(); - for (const request of normalized) { - seen.set(JSON.stringify(requestRecord(request)), request); - } - return Object.freeze( - [...seen.entries()] - .sort(([one], [other]) => (one < other ? -1 : one > other ? 1 : 0)) - .map(([, request]) => request), - ); -} - -/** A frozen deep copy of one JSON value the host stated. */ +/** A frozen deep copy of one JSON value the answer carried. */ function detach(value: T): T { return freeze(structuredClone(value)); } diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 2967ab7fd..b77179ad4 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -143,7 +143,12 @@ import { installIdentities, parseFormDeclaration, } from "./invocation-identity.ts"; -import type { IdentityComponent, ImportSelection } from "./invocation-identity.ts"; +import type { + FormSyntax, + IdentityComponent, + IdentityInstallation, + ImportSelection, +} from "./invocation-identity.ts"; import { CanonicalImports, ExecutionImports } from "./components/component-resolution.ts"; import type { ImportTier } from "./components/component-resolution.ts"; import type { ExecutionEnvironment } from "./execution-environment.ts"; @@ -154,20 +159,22 @@ import { } from "./components/protected.ts"; import { coreComponentDefinition, CORE_ORIGIN } from "./components/registry.ts"; import { rootSyntaxReference } from "./syntax-reference.ts"; +import type { SyntaxReference } from "./syntax-reference.ts"; import { useRootSyntaxReference } from "./syntax-api.ts"; import { capturedDocumentation } from "./documentation-api.ts"; import { - CANONICAL_PROFILE_ANSWERS, + captureEvaluationProfile, + evaluationLifetime, EvaluationProfileError, - prepareEvaluationProfile, - TWO_PROFILES, } from "./evaluation-profile.ts"; import type { - FragmentEvaluationInput, - PreparedProfile, + EvaluationLifetime, + EvaluationProfile, ResolvedAnswer, ResolvedAnswers, } from "./evaluation-profile.ts"; +import { admitGeneratedSelection } from "./generated-selection.ts"; +import type { AdmittedSelection } from "./generated-selection.ts"; import { componentAnswerRegistrar } from "./component-answers.ts"; import type { ComponentAnswerInstallation } from "./component-answers.ts"; import { packagedAssetReader } from "./component-documentation.ts"; @@ -755,32 +762,17 @@ function* resolveImportUnrecorded( return yield* materializeImport(name, selection, inputs, claimed); } -/** What a provider-backed name no provider published an answer for refuses with. */ +/** What a selected name this execution could not retain an implementation for refuses with. */ const UNPUBLISHED_ANSWER = - "an evaluation profile admits a component answer, and no provider published the " + - "implementation the import chain answered with. A fragment runs an answer its provider " + - "published, or it runs none."; - -/** - * What canonical core itself holds for this name, or nothing. - * - * A protected component's implementation is the one this execution built; an - * ordinary core component's is the definition core registered. Both are core's - * own object, and object identity is the whole check: an answer that is - * anything else — a repository component of the same name, a registration a - * nested scope layered over, a middleware's replacement, a look-alike carrying - * core's own generated URLs — is not this, so core publishes nothing for it and - * the entry refuses it. - */ -function sealedByCore(inputs: ImportInputs, name: string): FunctionComponentDefinition | undefined { - return inputs.guarded.get(name) ?? coreComponentDefinition(name); -} + "a host selected a name for generated use, and the implementation the import chain answered " + + "with is neither canonical resolution's own answer nor one a provider published. A fragment " + + "runs the implementation its execution retained, or it runs none."; /** - * Resolve every provider-backed name this profile admits, once, before the root - * import and before any document code exists. + * Resolve every name this host selected, once, before public execution policy, + * before the root import and before any document code exists. * - * Eager on purpose. A profile that resolved a name lazily would resolve it + * Eager on purpose. A selection that resolved a name lazily would resolve it * through whatever middleware the running document had arranged by then, which * is exactly the substitution an admitted identity exists to close. So the * lookup happens here, through the complete ordinary `Component.importComponent` @@ -789,37 +781,39 @@ function sealedByCore(inputs: ImportInputs, name: string): FunctionComponentDefi * is not authored work, and a durable record for it would be an effect a * continuation then had to account for. * - * What comes back is asked about once. Whether a provider published the *exact - * final answer* is asked of that object rather than of anything the chain held - * in between, and that one call answers with core's own copy of what was - * published — taken when it was published, before the chain could see it again - * — together with the publishing installation's origin. So the value this run - * keeps is the value the check was made about: there is no second read of the - * chain's object for an alternating proxy or a computed member to answer - * differently. - * - * What each admitted form of that definition runs as is the definition's own, - * and the profile holds every admitted form to it as it seals. + * What comes back is asked about once, and there are exactly two ways it can be + * an implementation this execution may retain. A provider may have published + * it, through the request it was handed. Or canonical resolution produced it + * and core published it there: core's defaults, its fixed filesystem + * components, the protected tier, a registration a package made and a + * repository component are all legitimate answers for a selected name, and + * requiring a provider to publish them again would make a host install a + * provider to be given what it already resolves to. + * + * Either way the publication is made where the answer was produced, not where + * it came back. So a handler that replaces the answer returns something nobody + * published, and one that edits the canonical answer in place is compared + * against the copy taken at publication — both refuse. + * + * Either way the one call that identifies the answer is the call that answers + * with core's own copy of it, taken when it was published and before the chain + * could see it again. So the value this run keeps is the value the check was + * made about: there is no second read of the chain's object for an alternating + * proxy or a computed member to answer differently. + * + * What each selected form of that definition runs as is the definition's own, + * and the capture holds every selected form to it. */ -function* resolveComponentAnswers( - prepared: PreparedProfile, +function* resolveSelectedComponents( + names: readonly string[], imports: CanonicalImports, inputs: ImportInputs, ): Operation { const answers = new Map(); - if (prepared.answered.length === 0) { - // A capability-only profile performs no component-chain lookup at all. + if (names.length === 0) { return answers; } - // The protected names this profile admits. A protected component is nobody's - // to answer for, so no provider publishes what is behind one — core does, - // here, and only for the one protected name it offers a host an entry for. A - // host that admits any other name is still the provider's to publish, exactly - // as it was. - const canonical = new Set( - prepared.answered.filter((name) => CANONICAL_PROFILE_ANSWERS.has(name)), - ); - const canonicalProvider = canonical.size === 0 ? undefined : imports.provider(CORE_ORIGIN); + const canonicalProvider = imports.provider(CORE_ORIGIN); yield* scoped(function* () { yield* Component.around( { @@ -828,17 +822,11 @@ function* resolveComponentAnswers( // Published from the innermost position, on the exact object // materialization produced, so a handler composed around this one // publishes nothing: middleware that returns this answer unchanged - // leaves the publication on it, and middleware that returns anything - // else returns something nobody published. A foreign implementation - // carrying core's own URLs is not this object, so copying them - // publishes nothing either. - if ( - canonicalProvider === undefined || - !canonical.has(name) || - answer !== sealedByCore(inputs, name) - ) { - return answer; - } + // leaves the publication on it, middleware that edits it in place is + // compared against the copy taken here, and middleware that returns + // anything else returns something nobody published. A foreign + // implementation carrying core's own URLs is not this object, so + // copying them publishes nothing either. const asked = canonicalProvider.open(name, position); try { return asked.request.answer(answer); @@ -849,16 +837,12 @@ function* resolveComponentAnswers( }, { at: "min" }, ); - for (const name of prepared.answered) { + for (const name of names) { // One resolution, one publication window. It opens before the chain is // asked and closes however this iteration leaves — answered, refused, or // cancelled partway through — so a handler that lost this decision, or // one still holding its request while the *next* name resolves, publishes // nothing into a decision that is already made. - // - // One call, one result. The publication and core's copy of what was - // published come back together, and the object the chain returned is not - // read again — so nothing this run keeps was decided by a second read. const resolution = imports.beginResolution(name); let identified; try { @@ -877,11 +861,11 @@ function* resolveComponentAnswers( // An answer this execution could not keep a copy of never reaches here: // identification refuses it above, because there would be nothing to hand // back. What is left is an answer that copied fine and is not something a - // fragment can invoke. + // fragment can invoke — a Markdown definition, or a structural one. if (own.kind !== "function" || typeof own.fn !== "function") { throw new EvaluationProfileError( - `an evaluation profile admits <${name} />, and the import chain answered with a ` + - "definition that has no implementation to invoke. An admitted entry runs something.", + `a host selected <${name} /> for generated use, and the import chain answered with a ` + + "definition that has no implementation to invoke. A selected name runs something.", ); } answers.set(name, { definition: own, origin }); @@ -2376,45 +2360,241 @@ export interface DocumentExecution extends Operation> { * }, execution.output); * ``` */ -function* executeDocument( - options: ExecuteOptions, - admissions: readonly JournalAdmission[] = [], - completions: readonly CompletionFailure[] = [], - preparations: readonly DurablePreparation[] = [], - bundles: readonly WorkflowComponentBundle[] = [], - identityComponents: readonly IdentityComponent[] = [], - installed: readonly RetainedInstallation[] = [], +/** + * Everything canonical component resolution establishes, assembled before + * public execution policy can observe or replace any of it. + * + * It is one value rather than a set of separate results because every member + * of it is decided from the same reading: the registrations the execution + * started with, the bundle it is closed over, the declarations its host made, + * the protected tier, the identity domains it minted, and the implementations + * the host's generated selection resolved to. Authored execution reuses this + * assembly rather than building a second one, so what a fragment was admitted + * under and what a document imports through are the same decisions. + */ +interface ComponentAssembly { + readonly startingRegistry: ComponentRegistry; + readonly bundle: WorkflowComponentCatalog | undefined; + readonly catalog: ExecutionDeclarationCatalog; + readonly installedComponents: InstalledComponents | undefined; + readonly identity: IdentityInstallation; + readonly forms: FormSyntax; + readonly imports: ExecutionImports; /** - * The documentation each bootstrapped package contributed. + * This execution's own description, as a function of the search paths it + * accepted. * - * Carried by value from the collection boundary, so the index a document's - * own `` reads is the index the profile actually - * assembled. + * Every other input the description is built from was captured before policy + * ran and is closed over here. The search paths are not, because a policy may + * legitimately replace them through `request.withOptions()`, and a + * description built from paths an authored import no longer resolves through + * would describe a different execution than the one running. */ - documentation: readonly DocumentationContribution[] = [], - /** This execution's packaged-asset reader, carried by value from the caller. */ - readAsset: DocumentationReader = packagedAssetReader, + readonly describe: (includes: readonly string[]) => SyntaxReference; /** - * The one owner of every answer this execution produces or a provider - * identifies. + * What a generated fragment may do here, when this host selected anything. * - * Built before any installation ran, so a provider could state identities - * during profile capture; handed here so the component resolution this - * execution imports through is the same table those identity statements went - * into. + * Absent is a host that selected nothing, and `` refuses on that + * rather than inventing a selection for it. */ - canonicalImports: CanonicalImports = new CanonicalImports(), + readonly evaluationProfile: EvaluationProfile | undefined; +} + +/** + * Assemble canonical component resolution, read the host's generated selection + * once, and retain what it selected. + * + * Called by the invocation owner, after initialization and the captured + * installation callbacks have assembled the host and before public + * `Execution.execute` policy. The order inside it is the contract: the registry, + * the bundle, the declarations, the protected tier and the identity domains are + * established first, because resolving a selected name goes through all of them; + * then the contextual selection is read and validated whole; then each selected + * name is resolved once and its implementation and metadata retained together. + * + * A policy that runs afterwards may still modify or refuse its document + * request, and its registry, import or selection replacement cannot replace or + * widen what is captured here — selection resolved against the host environment + * this established, including the search paths the host supplied. + * + * What it does *not* decide is how the document's own authored imports resolve. + * Those go through the options the canonical request settled to, so a policy + * that replaces the search paths through `request.withOptions()` still changes + * them; this capture keeps the paths it resolved against to itself. + */ +function* assembleComponents( + options: ExecuteOptions, + bundles: readonly WorkflowComponentBundle[], + identityComponents: readonly IdentityComponent[], + installed: readonly RetainedInstallation[], + documentation: readonly DocumentationContribution[], + canonicalImports: CanonicalImports, + lifetime: EvaluationLifetime, +): Operation { + // The paths *this capture* resolves against, copied: the selection below is + // resolved against the environment the host assembled, and a later edit to + // the caller's own array describes a different one. What the document's + // authored imports resolve against is the accepted request's, read there. + const includes = [...(options.includes ?? DEFAULT_INCLUDES)]; + // The compilers the admissions below compile schemas through, owned by the + // invocation that performs that admission: a compiler created per call would + // give one schema a fresh validator for every reading of it. + yield* usePropsCompiler(); + yield* useParseCompiler(); + + // The bundle this execution is closed over, resolved against the + // registrations it starts with. A name the engine, a core default, or a + // reserved registration already owns is refused here — before the journal + // is read, before the root document is imported, and before any component + // runs — because a bundle that could take one back would change what a + // document already written means. + const startingRegistry = yield* Component.operations.registry; + const bundle = installedBundle(bundles, startingRegistry); + + // Everything this host declares, admitted before the journal is read, + // before the root document is imported, and before any component runs. A + // declaration that disagrees with the source it names, that claims a name + // a host already reserved, or that is half of a structural pair describes + // a document that cannot mean what it says. + const catalog = yield* admitInstalledDeclarations(installed, startingRegistry); + const admittedDeclarations = catalog.markdown(); + const declaredMarkdown = catalog.markdownCatalog(); + + // What this execution gives a durable identity to, from what installation + // declared before anything could observe or replace it. Each factory is + // called here, with a claimant this execution minted for that component + // and nothing else — delivered as the argument of this call and published + // nowhere. Registration goes through the ordinary path, so a refused one + // throws before `activate()`, and a claimant that was never activated + // answers for nothing. + const identity = installIdentities( + identityComponents, + admittedDeclarations.flatMap((declaration) => [...declaration.privates]), + // Core's own, minted the same way and registered nowhere. Every + // execution has them, whatever its host declared, which is what makes a + // protected name mean one thing everywhere. + EXECUTION_BUILT_COMPONENTS, + // Core's own as well, and already built: the filesystem components are + // ordinary definitions this copy of core made once, so every execution + // answers for them with that same object. + FIXED_COMPONENTS.map((component) => component.definition), + ); + yield* registerComponents(identity.registrations); + identity.activate(); + // The domains stop answering with the execution that minted them, so an + // implementation kept past teardown names nothing. + yield* ensure(() => identity.identities.revoke()); + // This execution's own selection frames, held here and handed to core's + // expansion by value. Nothing a document, a component or middleware can + // name reaches them. + const forms = installFormSyntax(); + // The private closures, built from the minted implementations rather than + // from the declarations: what a private name resolves to is the function + // this execution built, and it names nothing once the execution is torn + // down above. + const installedComponents = + declaredMarkdown === undefined + ? undefined + : new InstalledComponents( + declaredMarkdown, + identity.privates, + new Map( + admittedDeclarations.map((declaration) => [ + declaration.name, + privateClosure(declaration.privates), + ]), + ), + ); + // The bundle first, so a bundled execution words every refusal exactly as + // it always did; a tier answers for the names it claims and the first one + // answers for the rest. + const tiers: ImportTier[] = []; + if (bundle !== undefined) { + tiers.push(bundle); + } + if (installedComponents !== undefined) { + tiers.push(installedComponents); + } + // Last, because a tier that claims a name answers for it and the earlier + // ones claim names of their own; a bundled execution still words every + // other refusal exactly as it always did. Present in every execution, + // because a protected name is closed in every execution. + tiers.push(new ProtectedImports()); + const imports = new ExecutionImports(tiers, canonicalImports); + + // Built from what this execution captured before any installation, + // middleware or document code ran, except the search paths — those are the + // accepted request's, so the caller closes over this and supplies them. Asked + // only when an occurrence renders: a run whose document never writes + // `` enumerates nothing. + const declarations = installed.flatMap((one) => [...one.declarations]); + const describe = (includes: readonly string[]): SyntaxReference => + rootSyntaxReference( + { + includes, + registry: startingRegistry, + components: identityComponents, + declarations, + ...(bundle === undefined ? {} : { workflow: bundle }), + }, + documentation, + ); + + // The host's own selection, read exactly once and validated whole before a + // single name is resolved. One contextual read rather than a collection: a + // selection is one complete value a host composed, so there is no contributor + // count and no automatic union, and two middleware handlers are not by + // themselves a setup error. + const selection = admitGeneratedSelection(yield* Component.operations.generated); + // One import per selected name, through the ordinary chain and the canonical + // terminal behind it, with everything above already established and the root + // import not yet asked for. So the chain a capture resolves through is the + // ordinary one, and no document code has run to arrange it. + const evaluationProfile = + selection === undefined + ? undefined + : captureEvaluationProfile( + selection, + yield* resolveSelectedComponents([...selection.keys()], canonicalImports, { + searchPaths: includes, + registry: startingRegistry, + bundle, + declared: installedComponents, + catalog, + guarded: identity.protected, + }), + lifetime, + identity.componentRouting.project, + ); + + return { + startingRegistry, + bundle, + catalog, + installedComponents, + identity, + forms, + imports, + describe, + evaluationProfile, + }; +} + +function* executeDocument( + options: ExecuteOptions, /** - * The one evaluation profile this host stated, copied and bound, when it - * stated one. + * Canonical component resolution, already assembled by the invocation owner + * before public policy ran. * - * Carried by value from the collection boundary. It is sealed below, once - * the provider-backed names it admits have been resolved, and the sealed - * result is handed to core's own expansion on the private environment rather - * than through any context: it is the ceiling `` narrows from, and a - * document that could reach it could raise it. + * Carried by value rather than rebuilt. What a generated fragment was + * admitted under and what this document imports through are the same + * decisions, so a second assembly here would be a second answer to which + * implementation a name has. */ - prepared?: PreparedProfile, + assembly: ComponentAssembly, + admissions: readonly JournalAdmission[] = [], + completions: readonly CompletionFailure[] = [], + preparations: readonly DurablePreparation[] = [], /** * The root bindings this execution starts with, parsed and copied by the * invocation that captured them. @@ -2429,11 +2609,27 @@ function* executeDocument( const { stream, props = {}, + // From the options the canonical request settled to, like every other + // authored input here. A policy that replaced them through + // `request.withOptions()` replaced where this document's imports resolve + // from; what the pre-policy capture resolved against is its own and is not + // read again. includes = [...DEFAULT_INCLUDES], modifiers: customModifiers = {}, secretDetection, retainProcessOutput = true, } = options; + const { + startingRegistry, + bundle, + catalog, + installedComponents, + identity, + forms, + imports, + describe, + evaluationProfile, + } = assembly; // Carried through exactly as supplied. Rewriting an identity here would let // the same value inspect and execute under different ones. @@ -2503,8 +2699,6 @@ function* executeDocument( // failure from. All created here and reclaimed with this task, so nothing // a run decided outlives it. yield* useSegmentCauses(); - yield* usePropsCompiler(); - yield* useParseCompiler(); const liveFailure: LiveFailureSlot = {}; yield* LiveFailure.set(liveFailure); @@ -2514,107 +2708,6 @@ function* executeDocument( // renderChildren → importComponent → createDurableOperation. const rootEvalScope = yield* useEvalScope(); - // The bundle this execution is closed over, resolved against the - // registrations it starts with. A name the engine, a core default, or a - // reserved registration already owns is refused here — before the journal - // is read, before the root document is imported, and before any component - // runs — because a bundle that could take one back would change what a - // document already written means. - const startingRegistry = yield* Component.operations.registry; - const bundle = installedBundle(bundles, startingRegistry); - - // Everything this host declares, admitted before the journal is read, - // before the root document is imported, and before any component runs. A - // declaration that disagrees with the source it names, that claims a name - // a host already reserved, or that is half of a structural pair describes - // a document that cannot mean what it says. - const catalog = yield* admitInstalledDeclarations(installed, startingRegistry); - const admittedDeclarations = catalog.markdown(); - const declaredMarkdown = catalog.markdownCatalog(); - - // What this execution gives a durable identity to, from what installation - // declared before anything could observe or replace it. Each factory is - // called here, with a claimant this execution minted for that component - // and nothing else — delivered as the argument of this call and published - // nowhere. Registration goes through the ordinary path, so a refused one - // throws before `activate()`, and a claimant that was never activated - // answers for nothing. - const identity = installIdentities( - identityComponents, - admittedDeclarations.flatMap((declaration) => [...declaration.privates]), - // Core's own, minted the same way and registered nowhere. Every - // execution has them, whatever its host declared, which is what makes a - // protected name mean one thing everywhere. - EXECUTION_BUILT_COMPONENTS, - // Core's own as well, and already built: the filesystem components are - // ordinary definitions this copy of core made once, so every execution - // answers for them with that same object. - FIXED_COMPONENTS.map((component) => component.definition), - ); - yield* registerComponents(identity.registrations); - identity.activate(); - // The domains stop answering with the execution that minted them, so an - // implementation kept past teardown names nothing. - yield* ensure(() => identity.identities.revoke()); - // This execution's own selection frames, held here and handed to core's - // expansion by value. Nothing a document, a component or middleware can - // name reaches them. - const forms = installFormSyntax(); - // The private closures, built from the minted implementations rather than - // from the declarations: what a private name resolves to is the function - // this execution built, and it names nothing once the execution is torn - // down above. - const installedComponents = - declaredMarkdown === undefined - ? undefined - : new InstalledComponents( - declaredMarkdown, - identity.privates, - new Map( - admittedDeclarations.map((declaration) => [ - declaration.name, - privateClosure(declaration.privates), - ]), - ), - ); - // The bundle first, so a bundled execution words every refusal exactly as - // it always did; a tier answers for the names it claims and the first one - // answers for the rest. - const tiers: ImportTier[] = []; - if (bundle !== undefined) { - tiers.push(bundle); - } - if (installedComponents !== undefined) { - tiers.push(installedComponents); - } - // Last, because a tier that claims a name answers for it and the earlier - // ones claim names of their own; a bundled execution still words every - // other refusal exactly as it always did. Present in every execution, - // because a protected name is closed in every execution. - tiers.push(new ProtectedImports()); - const imports = new ExecutionImports(tiers, canonicalImports); - - // The one place a provider-backed profile name is resolved, and the last - // thing that happens before the environment exists: the registry, the - // bundle, the declarations, the protected table and the identity domains - // are all established, and the root import has not been asked for. So the - // chain a capture resolves through is the ordinary one, and no document - // code has run to arrange it. - const evaluationProfile = - prepared === undefined - ? undefined - : yield* prepared.seal( - yield* resolveComponentAnswers(prepared, canonicalImports, { - searchPaths: includes, - registry: startingRegistry, - bundle, - declared: installedComponents, - catalog, - guarded: identity.protected, - }), - identity.componentRouting.project, - ); - /** * Resolve one component import the way this execution resolves them, and * the only thing that puts an invocation in one of its identity domains. @@ -2668,20 +2761,12 @@ function* executeDocument( return imports === undefined ? definition : imports.issue(name, definition); } - // Built from what this execution captured before any installation, - // middleware or document code ran, and asked only when an occurrence - // renders: a run whose document never writes `` enumerates - // nothing. - const derivedSyntax = rootSyntaxReference( - { - includes, - registry: startingRegistry, - components: identityComponents, - declarations: installed.flatMap((one) => [...one.declarations]), - ...(bundle === undefined ? {} : { workflow: bundle }), - }, - documentation, - ); + // What this execution describes itself with: everything the assembly + // captured before any installation, middleware or document code ran, + // against the search paths the request settled to. `xmd syntax` and an + // authored `` enumerate the components a document here can + // actually import, so the paths they read are the accepted ones. + const derivedSyntax = describe(includes); // Bound as the contextual Api's fallback, not installed in front of // anything: a host that stated its own profile with `useSyntax()` still // answers, and this is what answers when none did. @@ -2979,20 +3064,6 @@ export interface ExecutionInstallation { * operation after its invocation. */ readonly components?: readonly IdentityComponent[]; - /** - * The maximum permission a generated fragment may be evaluated under here. - * - * `` is public, so any author may write it; the permission ceiling - * comes only from the trusted host. Nothing a running document reaches names - * this — not a context, whose name is not a secret; not a registry, which a - * nested scope layers over; not a prop, which an author writes. - * - * Omitted is the ordinary case for a host that offers no evaluation at all: - * `` then refuses before the producer runs, rather than evaluating - * under a ceiling nobody stated. One execution accepts one; two are refused - * rather than ordered. - */ - readonly evaluation?: FragmentEvaluationInput; /** * The providers behind this installation's `component-answer` entries. * @@ -3534,7 +3605,7 @@ function* invoke( // differently the second time would let a host be tested for one provider and // install another, and a provider installer added from inside an earlier // `install()` would be one nothing captured. - const assembly = Object.freeze( + const installers = Object.freeze( installations.map((installation) => Object.freeze({ providers: Object.freeze( @@ -3547,35 +3618,16 @@ function* invoke( ), ); - // The maximum permission a generated fragment may be evaluated under here, - // read on the same terms and for a stronger reason: two profiles would be two - // answers to what a fragment may *do*, and choosing between them by - // installation order would make permission depend on assembly. A host that - // stated none offers no evaluation at all, which `` refuses with - // rather than inventing a ceiling for. - const stated = installations.flatMap((installation) => { - const profile = installation.evaluation; - return profile === undefined ? [] : [profile]; + // The lifetime every captured generated implementation is held to, opened + // and armed before a single `install()` runs — so what a capture retains is + // revoked however the execution ends, including when it is halted partway + // through assembly. A fragment body, a handler or a retained callback that + // reaches one afterwards refuses rather than acting on a filesystem the run + // no longer holds a transaction for. + const lifetime = evaluationLifetime(); + yield* ensure(function* () { + lifetime.revoke(); }); - if (stated.length > 1) { - throw new Error(TWO_PROFILES); - } - // Copied and bound here, before any `install()` runs, so a host that mutates - // its own tables, schemas or headers from inside one changes nothing this - // execution does. What it cannot settle yet is what a provider-backed name - // resolves to: that takes the complete ordinary import chain, and the - // providers have not installed. - const prepared = stated[0] === undefined ? undefined : yield* prepareEvaluationProfile(stated[0]); - if (prepared !== undefined) { - // Registered before the first `install()`, so the operations this profile - // bound are revoked however the execution ends — returning, failing, or - // being halted partway through assembly. A fragment body, a handler or a - // retained callback that reaches one afterwards refuses rather than acting - // on a filesystem the run no longer holds a transaction for. - yield* ensure(function* () { - prepared.revoke(); - }); - } // The one owner of every answer this execution produces or a provider // identifies. Constructed inactive, and activated only after its teardown is @@ -3600,7 +3652,7 @@ function* invoke( // in the order the installations were captured. A provider registers // middleware through its registrar, and each invocation states what it is // returning through a fresh request fixed to the provider's origin. - for (const { providers, install } of assembly) { + for (const { providers, install } of installers) { for (const { origin, install: installProvider } of providers) { yield* installProvider(componentAnswerRegistrar(canonicalImports.provider(origin))); } @@ -3640,21 +3692,31 @@ function* invoke( }, }); + // Canonical component resolution and the host's generated selection, both + // established here: after initialization and the captured installation + // callbacks have assembled the host, and before the public policy below. A + // policy that replaces a registration, imports a different body or supplies a + // broader selection afterwards changes neither the vocabulary nor the + // implementations this retained. + const assembly = yield* assembleComponents( + options, + bundles, + identityComponents, + installed, + documentation, + canonicalImports, + lifetime, + ); + // Whatever a handler returns is not an execution, so it is not read. yield* invocationExecution.operations.execute(issued.request); return yield* executeDocument( issued.settle(), + assembly, admissions, issued.completions(), preparations, - bundles, - identityComponents, - installed, - documentation, - readAsset, - canonicalImports, - prepared, initialBindings, ); } diff --git a/packages/core/src/fragment-capabilities.ts b/packages/core/src/fragment-capabilities.ts deleted file mode 100644 index b10899ae5..000000000 --- a/packages/core/src/fragment-capabilities.ts +++ /dev/null @@ -1,489 +0,0 @@ -/** - * Which component an admitted fragment runs, and for how long it may run it. - * - * A host states that a generated fragment may read a file, and this is what - * decides *what* answers that name. The answer is canonical core's own ordinary - * component — the same ``, ``, ``, ``, `` - * and `` an author writes — so a generated element and an authored one - * are one implementation performing one operation through one provider. - * - * There is deliberately nothing else here. The operations used to be a private - * transport: a host handed its own filesystem object to the profile, capture - * read each method off it once, and core closed a fragment-facing body over the - * bound result. That made a generated read a second filesystem implementation, - * invisible to the `API.Files` middleware every other read crosses, and it put - * a fragment's working directory in a cursor this module owned rather than in - * the contextual environment the rest of the engine uses. Both are gone. What a - * fragment may do is now mediated where every other caller's operations are - * mediated — by the installed provider, and by `Permissions` at the - * implementation that performs the I/O. - * - * ## What a host still decides - * - * Everything about admission, and nothing about performance: - * - * - **which names** a fragment may write, and under which identity a - * continuation compares them; - * - **which spelling** each name is admitted for, so `` observing and - * `…` writing are two grants rather than one — decided in - * preflight, from the scan, before the fragment's first effect; - * - **which requests** an admitted `` may perform, as an exact ceiling - * belonging to that entry; - * - **how long** any of it lasts: every admitted implementation is held to the - * execution that captured it, and refuses once that execution has ended. - * - * What it no longer decides is which filesystem or transport the operation - * reaches. That was never a property of the name — it is the installed - * provider's, and a workflow run's transaction-bound provider is as much the - * ordinary route as the host adapter is. - * - * ## Why the engine still selects the component's own dispatcher - * - * `` runs two irreversible bodies, and which one an element gets is - * canonical dispatch's decision, taken from the scan. A dispatcher refuses an - * invocation whose selection is not itself, so an adapter that wrapped one in a - * second dispatcher would be an adapter no form could get through. The guard - * below is therefore a plain wrapper — it opens no scope and selects no body — - * and the component's own dispatcher travels beside it as the entry's - * `dispatch`, where canonical selection records it. - */ - -import { scoped } from "effection"; -import type { Operation } from "effection"; - -import type { - FunctionComponent, - FunctionComponentDefinition, - Json, - PropsSchema, - ReturnsSchema, -} from "./types.ts"; -import { getExpansion } from "./expansion.ts"; -import { observeFetch } from "./components/Fetch.ts"; -import { persistFetch } from "./fetch-journal.ts"; -import { parseResponseRecord } from "./fetch-response.ts"; -import { fixedFilesystemDefinition } from "./components/fixed-filesystem.ts"; -import { coreComponentDefinition, ELICIT_COMPONENT } from "./components/registry.ts"; -import { FileAccessError } from "./components/File.ts"; -import { FileDeleteError } from "./components/FileDelete.ts"; -import { DirInvocationError } from "./components/Dir.ts"; -import { GlobError } from "./components/Glob.ts"; -import { markGeneratedRequestRefusal } from "./generated-request-refusal.ts"; -import { isFormDispatcher } from "./invocation-identity.ts"; -import type { ComponentInvocation } from "./invocation-identity.ts"; -import type { FetchRequest } from "./fetch-request.ts"; -import { Permissions } from "@executablemd/runtime"; - -/** A refusal an admitted fragment's own request produced. */ -export class FragmentCapabilityError extends Error { - override name = "FragmentCapabilityError"; -} - -/** What a fragment operation reached after its execution had ended. */ -export const REVOKED_CAPABILITY = - "an admitted fragment reached an operation belonging to an execution that has ended."; - -/** Whether the execution that admitted these components is still running. */ -export interface CapturedCapabilities { - /** Whether the execution that captured these is still running. */ - live(): boolean; - /** Called at execution teardown. Every admitted implementation refuses afterwards. */ - revoke(): void; -} - -/** - * The lifetime every admitted implementation in one capture is held to. - * - * All a capture owns now. There is no operation to read off a host object and - * nothing to bind, because the implementations are core's own — so what remains - * is the one thing an execution has to be able to say afterwards: this run is - * over, and a definition somebody kept is not a way back into it. - */ -export function captureCapabilities(): CapturedCapabilities { - let alive = true; - return Object.freeze({ - live: () => alive, - revoke: () => { - alive = false; - }, - }); -} - -/** Which ordinary component one admitted entry runs. */ -export type FragmentCapability = - | "file:read" - | "files:glob" - | "file:write" - | "file:delete" - | "directory:ensure" - | "fetch" - | "elicit:ask"; - -/** The authored forms each capability is written in. */ -export const CAPABILITY_FORMS: Readonly< - Record -> = Object.freeze({ - "file:read": ["self-closing"], - "files:glob": ["self-closing"], - "file:write": ["paired"], - "file:delete": ["self-closing"], - "directory:ensure": ["paired"], - fetch: ["self-closing"], - // The question is the content, so there is nothing a self-closing spelling - // could be asking. - "elicit:ask": ["paired"], -}); - -/** The component canonical core runs for one capability. */ -const OWNERS: Readonly> = Object.freeze({ - "file:read": "File", - "file:write": "File", - "file:delete": "File.Delete", - "directory:ensure": "Dir", - "files:glob": "Glob", - fetch: "Fetch", - "elicit:ask": ELICIT_COMPONENT, -}); - -/** - * The exact definition canonical core supplies for one capability. - * - * Read from the one place that owns it — the fixed tier for the three - * filesystem names, the core registry for the rest — rather than restated here. - * A copy would be a second component, and a generated fragment behaving - * differently from an authored element is precisely what that drift looks like. - */ -function capabilityOwner(capability: FragmentCapability): FunctionComponentDefinition { - const name = OWNERS[capability]; - const definition = fixedFilesystemDefinition(name) ?? coreComponentDefinition(name); - if (definition === undefined) { - throw new FragmentCapabilityError(`canonical core supplies no <${name}> to admit.`); - } - return definition; -} - -/** The schema an admitted entry's props are validated against. */ -export function capabilityProps(capability: FragmentCapability): PropsSchema { - return capabilityOwner(capability).props; -} - -/** - * What a capability binds, for the ones that bind a value rather than render. - * - * The owning component's own declaration. Declaring it is what makes the - * admitted element a value component exactly as it does for the authored one: - * the engine requires `as`, renders nothing, and validates what comes back. - */ -export function capabilityReturns(capability: FragmentCapability): ReturnsSchema | undefined { - return capabilityOwner(capability).returns; -} - -/** Which capability one name is admitted for, in each authored spelling. */ -export interface AdmittedForms { - readonly "self-closing"?: FragmentCapability; - readonly paired?: FragmentCapability; -} - -/** One admitted name's implementation, and the dispatcher underneath it. */ -export interface CapabilityImplementation { - readonly definition: FunctionComponentDefinition; - /** - * The owning component's form dispatcher, when it has one. - * - * It travels separately because the definition above runs behind the lifetime - * guard, so what a selection would read off it is core's wrapper rather than - * the component's dispatcher — and a dispatcher read off the wrong function - * selects no body at all. - */ - readonly dispatch?: unknown; -} - -/** - * The implementation one admitted name runs, built once per capture. - * - * One definition per *name*, however many capabilities that name holds: a - * profile admitting `` to read and `…` to write is - * describing one component with two spellings, and the evaluator resolves an - * import by name. Which spelling reaches which body is the component's own - * dispatch, from the scan; which spellings are admitted at all is the entry's, - * in preflight. - */ -export function capabilityImplementation( - name: string, - admitted: AdmittedForms, - capabilities: CapturedCapabilities, - requests: readonly FetchRequest[] = [], -): CapabilityImplementation { - const held = [admitted["self-closing"], admitted.paired].filter( - (capability): capability is FragmentCapability => capability !== undefined, - ); - if (held.length === 0) { - throw new FragmentCapabilityError(`an evaluation profile admitted "${name}" for no form.`); - } - const owners = new Set(held.map((capability) => OWNERS[capability])); - if (owners.size > 1) { - // One name is one component. Two capabilities owned by different components - // under one name would be one import with two answers, decided by how the - // element happened to be written. - throw new FragmentCapabilityError( - `an evaluation profile admitted "${name}" for two different components: ` + - `${[...owners].join(" and ")}. One name runs one component.`, - ); - } - const owner = capabilityOwner(held[0]); - const inner = owner.fn; - if (typeof inner !== "function") { - throw new FragmentCapabilityError( - `canonical core supplies no invocable <${owner.name}> to admit.`, - ); - } - const fn = - owner.name === "Fetch" - ? bounded(admitted, capabilities, boundedRequests(inner, requests)) - : bounded(admitted, capabilities, inner); - return Object.freeze({ - definition: Object.freeze({ ...owner, name, fn }), - ...(isFormDispatcher(inner) ? { dispatch: inner } : {}), - }); -} - -/** - * One admitted implementation, held to this execution and reporting a refusal - * of the request as one. - * - * Two jobs, and both of them are the adapter's rather than the component's. The - * lifetime is the execution's: a definition retained past teardown refuses - * instead of reaching a filesystem the run no longer holds a transaction for. - * The classification is the caller's: a host driving a loop needs to know - * whether the fragment's own text was refused, and only the code that raised - * the failure knows that. - * - * It opens no scope and selects no body, so the component's own dispatcher - * still runs in the frame the engine issued it for. - */ -function bounded( - admitted: AdmittedForms, - capabilities: CapturedCapabilities, - inner: FunctionComponent, -): FunctionComponent { - return function* held( - props: Record, - invocation: ComponentInvocation, - ): Operation { - if (!capabilities.live()) { - throw new FragmentCapabilityError(REVOKED_CAPABILITY); - } - try { - return yield* inner(props, invocation); - } catch (error) { - if (!(error instanceof Error)) { - throw error; - } - const reason = refusedReason(admitted, props, invocation, error); - if (reason === undefined) { - throw error; - } - throw markGeneratedRequestRefusal(error, reason); - } - }; -} - -/** The verb a refusal of each filesystem capability is reported with. */ -const VERBS: Readonly>> = Object.freeze({ - "file:read": "read", - "file:write": "write", - "file:delete": "delete", - "directory:ensure": "create", -}); - -/** - * The safe reason this failure carries, when it is a refusal of the request. - * - * Core's own fixed sentence, not the component's. The component's message is - * safe to *print* — it names the path the document wrote and a phrase from a - * fixed vocabulary — but a write that failed because its content failed to - * expand reports what that content said, and the content of a generated - * fragment is text nobody audited. A refusal reason travels back to whatever is - * driving the loop, so it is built here from the capability and the authored - * path, and from nothing the fragment produced. - * - * The glob sentences are the exception and are already written for this: a - * pattern a fragment wrote wrongly is reported by `glob-source.ts` in the same - * words an author would be told, and no provider diagnostic reaches them. - * - * Everything else is unmarked, which is the safe default. An absent provider, a - * broken provider contract, a revoked execution, a cancellation and a durable - * failure are not the request being refused, and calling any of them one would - * invite another turn at work that cannot succeed. - */ -function refusedReason( - admitted: AdmittedForms, - props: Record, - invocation: ComponentInvocation, - error: unknown, -): string | undefined { - if (error instanceof GlobError) { - return error.message; - } - if ( - !( - error instanceof FileAccessError || - error instanceof FileDeleteError || - error instanceof DirInvocationError - ) - ) { - return undefined; - } - const verb = VERBS[writtenCapability(admitted, invocation) ?? "fetch"]; - return verb === undefined - ? undefined - : `an admitted fragment could not ${verb} ${JSON.stringify(String(props.path))}.`; -} - -/** - * Which of a name's capabilities this element was written as. - * - * Read from the authored shape, which is the only thing that separates - * `` from `…` once both are admitted under one name. This - * is **not** how the effect was selected: the component's own dispatcher - * already did that, from the scan, before its body ran — and the failure this - * words a sentence for has already happened. A forged invocation can therefore - * change which verb a refusal is phrased with and nothing else, which is why - * reading it here is observation rather than authentication. - */ -function writtenCapability( - admitted: AdmittedForms, - invocation: ComponentInvocation, -): FragmentCapability | undefined { - try { - return invocation.hasContent() ? admitted.paired : admitted["self-closing"]; - } catch { - return undefined; - } -} - -/** - * The admitted request this element matched, performed under its entry's - * ceiling. - * - * Preflight already compared the element against the ceiling, from the scan, - * before the fragment's first effect. This finds the admitted request it - * matched and performs *that* — never the props, which would let a header the - * ceiling normalized away travel anyway, and never a timeout read from - * wherever the performance happened rather than the one the host resolved when - * it stated the profile. - * - * The ceiling is then installed as an ordinary `Permissions` restriction for - * the duration of this one invocation, so it stays in force at the place the - * request is actually sent: contextual `API.Fetch` middleware may observe, - * sign or route what goes out, and a transformation that moves the request off - * this entry's ceiling is refused by the terminal rather than performed. - * - * The journal, the response detachment and the status rule are the ordinary - * component's, reached through the shared observation it exports: one element - * is one durable request, and a continuation restores what came back rather - * than asking again. - */ -function boundedRequests( - inner: FunctionComponent, - requests: readonly FetchRequest[], -): FunctionComponent { - // The baseline, taken once and kept as strings. A comparison against the - // objects this entry hands the transport would be a comparison against - // something the chain can edit: the request travels through middleware by - // reference, so an in-place edit would rewrite both what the restriction - // checks and what a later invocation of this entry treats as admitted. - // Strings cannot be edited from anywhere. - const ceiling: ReadonlySet = new Set( - requests.flatMap((request) => { - // An admitted request cannot be ambiguous — `admitHeaders` refuses two - // spellings of one name before a ceiling is ever normalized — so this - // drops nothing in practice and refuses rather than guesses if it ever - // could. - const held = signature(request.url, request.method, request.headers); - return held === undefined ? [] : [held]; - }), - ); - return function* performed(props: Record): Operation { - const url = String(props.url); - const admitted = requests.find((request) => request.url === url); - if (admitted === undefined) { - throw new FragmentCapabilityError( - "an admitted fragment asked for a request this host did not admit.", - ); - } - const expansion = yield* getExpansion(); - const retained = yield* scoped(function* () { - yield* Permissions.around({ - fetch: ([input, init], next) => { - const outgoing = signatureOf(input, init); - return outgoing !== undefined && ceiling.has(outgoing) ? next(input, init) : false; - }, - }); - return yield* persistFetch( - { - id: expansion.id, - ...(expansion.position === undefined ? {} : { position: expansion.position }), - }, - admitted, - // A copy per invocation, for the same reason the baseline is a string: - // what goes down the chain is editable by anything in it, and this - // entry's ceiling must not be what gets edited. - () => observeFetch({ ...admitted, headers: { ...admitted.headers } }), - ); - }); - // The status rule is the authored one: a fragment binds nothing, so a - // response it cannot branch on must not be handed back as though it were an - // answer. - const response = parseResponseRecord(retained); - if (response.status < 200 || response.status > 299) { - throw new FragmentCapabilityError( - `an admitted fragment's request answered with status ${response.status}.`, - ); - } - return retained; - }; -} - -/** - * One request as the single string a ceiling is compared by. - * - * The destination, the method and the headers — the whole of what the host - * admitted. Normalized the way an authored request is: the method defaults to - * `GET`, header names are lowercased, and the names are sorted, so two - * spellings of one request produce one signature and comparison does not depend - * on the order a transformation happened to build its object in. - * - * Two names that become the same key answer `undefined` rather than one of - * them. An authored request is refused outright for that ambiguity - * (`fetch-request.ts`), and a request built by middleware says two things about - * one header; a signature that silently kept either would be deciding which. - * `undefined` matches no ceiling, so the ambiguity fails closed. - */ -function signature( - url: string, - method: string, - headers: Record | undefined, -): string | undefined { - const held = new Map(); - for (const [name, value] of Object.entries(headers ?? {})) { - if (typeof value !== "string") { - return undefined; - } - const key = name.toLowerCase(); - if (held.has(key)) { - return undefined; - } - held.set(key, value); - } - const ordered = [...held.keys()].sort().map((key) => [key, held.get(key)]); - return JSON.stringify([url, method, ordered]); -} - -/** The signature of the request that is actually about to be sent. */ -function signatureOf( - input: string, - init: { readonly method?: string; readonly headers?: Record } | undefined, -): string | undefined { - return signature(input, init?.method ?? "GET", init?.headers); -} diff --git a/packages/core/src/generated-selection.ts b/packages/core/src/generated-selection.ts new file mode 100644 index 000000000..8bac8ae24 --- /dev/null +++ b/packages/core/src/generated-selection.ts @@ -0,0 +1,146 @@ +/** + * The component forms a host offers to generated XMD, read once and validated + * whole (spec §5.3.2). + * + * `Component.generated` is a plain contextual value property, so what reaches + * core is one complete selection a host composed rather than a collection + * anybody contributed to. It names components and spellings and nothing else: + * which URL each spelling runs under, what its props are validated against, + * whether it observes or mutates, and what documentation describes it all belong + * to the declaration that owns the implementation + * (`components/generated-metadata.ts`). + * + * ## Read once into owned data + * + * A selection is a caller-owned object, and every member of it is read exactly + * once — into the map this module answers with — before anything is derived + * from it. Checking the host's object and then reading it again to retain it + * would be two readings of a value the host controls, where an accessor could + * answer one thing for the check and another for the copy. So the reading that + * was validated is the reading that is retained. + * + * ## Whole, or not at all + * + * `undefined` preserves absence: the host offers no generated evaluation, a + * document that never writes `` runs, and an `` refuses + * before its producer. Anything else is held to all of it — at least one name, + * every name a component name, every value a canonical non-empty form + * declaration — and a selection that fails any of it refuses setup. Nothing is + * repaired, filtered, merged, or replaced with a default, because a partially + * read selection would install an allowance no host stated. + */ + +import type { GeneratedSelection } from "./component-api.ts"; +import { isComponentName } from "./components/registration.ts"; +import { formsRefusal } from "./invocation-forms.ts"; +import type { InvocationForm } from "./invocation-identity.ts"; + +/** A selection a host stated in a shape canonical execution cannot capture. */ +export class GeneratedSelectionError extends Error { + override name = "GeneratedSelectionError"; +} + +/** + * One validated selection, in canonical order. + * + * Sorted by component name rather than kept in the order a host's object + * happened to enumerate in. Two middleware handlers compose one selection + * normally, so enumeration order is a property of the assembly rather than of + * what the host chose — and the retained admission compares the selected rows + * position by position. Deriving the order from the names makes that comparison + * a fact about the selection instead of about how it was built. + */ +export type AdmittedSelection = ReadonlyMap; + +/** + * The selection this host offers, or nothing when it offers none. + * + * The one place the contextual answer is interpreted. Every refusal happens + * here — before a name is resolved, before public execution policy, before the + * root import and before a producer renders — so a host that stated a selection + * core cannot capture has no partial allowance anywhere in the execution. + */ +export function admitGeneratedSelection( + selection: GeneratedSelection | undefined, +): AdmittedSelection | undefined { + if (selection === undefined) { + return undefined; + } + if (typeof selection !== "object" || selection === null || Array.isArray(selection)) { + throw new GeneratedSelectionError( + "a host offered generated components as something other than a map of component names to " + + "authored forms. The selection is one object naming what a fragment may write, and " + + "offering none is stating no selection at all.", + ); + } + const admitted = new Map(); + for (const key of Reflect.ownKeys(selection)) { + if (typeof key !== "string") { + throw new GeneratedSelectionError( + `a host offered generated use of "${String(key)}", which is not a component name.`, + ); + } + if (!isComponentName(key)) { + throw new GeneratedSelectionError( + `a host offered generated use of "${key}", which is not a component name.`, + ); + } + // One read of this member, into data this module owns. The copy below is + // what every check and the retained map read; the host's object is never + // consulted again. + admitted.set(key, readForms(key, Reflect.get(selection, key))); + } + if (admitted.size === 0) { + throw new GeneratedSelectionError( + "a host offered generated evaluation and selected no component. A host that offers " + + "evaluation states what a fragment may write; one that offers none states no selection.", + ); + } + return Object.freeze(new Map([...admitted].sort(([one], [other]) => compare(one, other)))); +} + +/** + * One name's spellings, copied once and held to the canonical declaration. + * + * The same contract a registration's `forms` is held to, through the same + * validator: one canonical spelling per meaning, so an empty array, a reversed + * pair, a repeated member and a form no invocation has are each refused where + * the selection is stated. Omission is not accepted here, because a selection + * states which spellings it offers — a name with no spelling offers nothing a + * fragment could write. + */ +function readForms(name: string, value: unknown): readonly InvocationForm[] { + if (!Array.isArray(value)) { + throw new GeneratedSelectionError( + `a host offered generated use of "${name}" as something other than a list of authored ` + + "forms.", + ); + } + // Each member read once, and parsed into one of the two forms rather than + // cast: what the canonical check below reads is this array, and what the + // selection retains is this array, so an accessor-backed index cannot answer + // one form for the check and another for the grant. + const forms: InvocationForm[] = []; + for (const member of value) { + const form = FORMS.find((known) => known === member); + if (form === undefined) { + throw new GeneratedSelectionError( + `a host offered generated use of "${name}" in a "${String(member)}" form, which is not ` + + "an authored form.", + ); + } + forms.push(form); + } + const refusal = formsRefusal(forms); + if (refusal !== undefined) { + throw new GeneratedSelectionError(`a host offered generated use of "${name}" and ${refusal}.`); + } + return Object.freeze(forms); +} + +/** The two authored forms, in the one order a canonical declaration states them. */ +const FORMS: readonly InvocationForm[] = ["self-closing", "paired"]; + +function compare(one: string, other: string): number { + return one < other ? -1 : one > other ? 1 : 0; +} diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index bd1fab52f..a0d428602 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -5,8 +5,8 @@ * An Agent proposes work by returning a fragment of Executable Markdown. That * fragment is untrusted input: it carries data, never authorization. So a * trusted host — never a document, a component, or a middleware package — hands - * this evaluator the candidate source together with the ceilings it may run - * under, and the ceilings are values captured before the fragment exists. + * this evaluator the candidate source together with the selection it may run + * under, and that selection is captured before the fragment exists. * * ## Nothing happens before the whole fragment has been read * @@ -17,12 +17,10 @@ * does. * * What is refused is a construct *class*: executable code blocks, expression - * props, interpolation that reads a binding, a result binding, a component the - * host did not admit, and a request that is malformed or outside the admitted - * set. The record and the diagnostic name the class and nothing else. Generated - * source is exactly the text a refusal must not echo — which is why a candidate - * request is normalized behind a converting boundary rather than allowed to - * report itself, and why a refusal is *returned* by the admission rather than + * props, interpolation that reads a binding, a result binding, and a component + * or form the host did not select. The record and the diagnostic name the class + * and nothing else. Generated source is exactly the text a refusal must not + * echo, which is why a refusal is *returned* by the admission rather than * thrown out of it: a thrown one is serialized into the journal with its * message and its stack. * @@ -64,22 +62,24 @@ * reading `` cannot be turned into a write, or an admitted writing * `…` into a read, however many times something is asked. * - * ## A resumed run is held to the ceilings it was admitted under + * ## A resumed run is held to the selection it was admitted under * * Durable replay matches an effect by its type and name; what a description * carries is stored, never compared. So the retained admission carries the * normalized policy in its **result** as well as in the event input, and a * continuation checks that retained policy against the one this run states * before a single generated component is invoked or a single effect is - * performed. The Workspace roots are as-of-admission provenance and are asked - * for by membership: the run's own progress legitimately retains further - * roots and advances the authoritative current root, while a run that no - * longer retains an admission root — or its selected root — is refused. Every - * other term compares whole and exactly: a changed class selection, a changed - * pinned identity behind the same name, a changed admitted form, and a - * widened request ceiling are each refused there. Only the tables the - * selection reached take part, so a write table a read-only admission never - * drew on may move without invalidating it. + * performed. Every term compares whole and exactly: a changed class selection, + * a changed identity behind the same name, and a changed admitted form are + * each refused there. Only the tables the selection reached take part, so a + * write table a read-only admission never drew on may move without + * invalidating it. + * + * What is deliberately absent is everything live. Which destination an admitted + * `` may reach, and what timeout it resolves, are decided where the + * request is performed — by the effective `Permissions` and `Config` of the + * scope that performs it — so a continuation held to a retained copy of either + * would refuse a run whose configuration legitimately moved. * * That is also why the walk lives inside the admission's live executor. A * continuation restores what was admitted without consulting the current source @@ -124,9 +124,6 @@ import type { GeneratedForms } from "./components/generated-metadata.ts"; import { acceptedForms, formsRefusal } from "./invocation-forms.ts"; import { createBlockCounter, expandSegmentsWithin } from "./expand.ts"; import { extendPath } from "./expansion.ts"; -import { prepareFetchRequest, requestRecord } from "./fetch-request.ts"; -import { timeoutFetch } from "@executablemd/runtime"; -import type { FetchRequest } from "./fetch-request.ts"; import { isJsonObject, parseJson } from "./json.ts"; import { markGeneratedRequestRefusal } from "./generated-request-refusal.ts"; import { GeneratedDataExpressions, validateDataExpression } from "./generated-expressions.ts"; @@ -185,8 +182,7 @@ type Construct = | "content" | "form" | "construct" - | "structure" - | "request"; + | "structure"; /** * The fixed diagnostics. @@ -218,20 +214,19 @@ const CONSTRUCT: Record = { structure: "a generated fragment writes a structural construct the language does not allow where it " + "was written.", - request: "a generated fragment asks for a request this host did not admit.", }; /** - * What a resumed run is refused with when its ceilings moved. + * What a resumed run is refused with when its selection moved. * * Fixed, like every other diagnostic here, and deliberately naming nothing it - * compared: which root, identity or request changed is exactly the material a + * compared: which name, identity or form changed is exactly the material a * refusal must not publish. */ const CEILING = - "a generated fragment was admitted under ceilings this run no longer states. A retained " + - "admission resumes only under the exact effect classes, Workspace roots, pinned identities, " + - "forms and requests it was admitted with."; + "a generated fragment was admitted under a selection this run no longer states. A retained " + + "admission resumes only under the exact effect classes, identities and forms it was admitted " + + "with."; const UNREADABLE = "the retained generated-XMD admission record cannot be read as one."; @@ -295,16 +290,6 @@ class Refusal extends Error { } } -/** - * One request a generated fragment may perform, written the way an element - * writes it. - * - * The host states the ceiling in the same vocabulary the candidate uses, and - * both sides go through `prepareFetchRequest()`, so "the same request" is one - * normalization rather than two readings that could disagree. - */ -export type GeneratedRequest = Record; - /** * The effect classes a host table is divided into. * @@ -395,11 +380,6 @@ export interface GeneratedObservation { * the ordinary case: the definition's own `fn` is the dispatcher. */ readonly dispatch?: unknown; - /** - * The exact requests this observation may perform, when it performs HTTP - * reads at all. Present only on the pinned `` entry. - */ - readonly requests?: readonly GeneratedRequest[]; /** * Whether only the self-closing form of this name is admitted. * @@ -424,25 +404,22 @@ export interface GeneratedObservation { export const JSON_COMPOSITION_IDENTITY = "https://executable.md/components/json"; /** - * The pinned core `` identity, bounded to exactly these requests. + * Core's own ``, as the evaluator admits it. * - * Core's own default definition, taken from the registry every execution and - * every inspection resolves through — not the name `Fetch`, which a repository - * file may also take. The ceiling is not optional: an unbounded network read is - * a different decision from an admitted one, and this constructor does not make - * it. + * Core's default definition, taken from the registry every execution and every + * inspection resolves through — not the name `Fetch`, which a repository file + * may also take. There is no request inventory beside it: which destination, + * method and headers an admitted read may actually reach is a live question, + * asked of the transformed request immediately before transport by whichever + * `Permissions` restriction the host installed in the owner that performs the + * work. */ -export function pinnedFetch(requests: readonly GeneratedRequest[]): GeneratedObservation { +export function pinnedFetch(): GeneratedObservation { const definition = CORE_REGISTRY.get("Fetch")?.default?.definition; if (definition === undefined || definition.kind !== "function") { throw new GeneratedXmdError("core supplies no Fetch component to admit."); } - if (requests.length === 0) { - throw new GeneratedXmdError( - "admitting to a generated fragment requires the exact requests it may perform.", - ); - } - return { name: "Fetch", definition, requests: [...requests] }; + return { name: "Fetch", definition }; } /** @@ -623,18 +600,6 @@ export interface GeneratedXmdRequest { readonly id: string; /** The candidate source, exactly as it was generated. */ readonly source: string; - /** - * The retained Workspace roots the host is willing to expose. - * - * Absent for a host that evaluates against no Workspace. An ordinary run is - * one: its admitted effects address the Files provider its own execution - * installed, and there is no immutable root history for a continuation to be - * held to. A workflow run states both, and a continuation is then held to - * that basis by membership. - */ - readonly workspaceRoots?: readonly string[]; - /** The one root admitted effects address, for a host that has one. */ - readonly selectedRoot?: string; /** The pinned observation entries the `read` class resolves to. */ readonly observations: readonly GeneratedObservation[]; /** The pinned mutation entries the `write` class resolves to. */ @@ -684,7 +649,6 @@ interface Entry { * select between. */ readonly effect: GeneratedEffectClass | "composition"; - readonly requests?: readonly GeneratedRequest[]; } /** @@ -711,31 +675,22 @@ interface RetainedInvocation { } /** - * The ceilings one fragment ran under, normalized. + * The selection one fragment ran under, as the run retains it. * - * Normalized because this is what a continuation is compared against: a request - * the host spelled differently but meant identically must compare equal, and - * one it meant differently must not. The selection is here too — only the - * tables `allow` chose take part, so a host that changed a table this admission - * never drew on has changed nothing this admission was granted under. + * Only the tables `allow` chose take part, so a host that changed a table this + * admission never drew on has changed nothing this admission was granted under. + * Nothing live is here: which destination an admitted request may reach and + * what timeout it resolves are decided where the request is performed, and a + * continuation compared against them would refuse a run whose configuration + * legitimately moved. */ interface Policy { readonly allow: readonly GeneratedEffectClass[]; - /** - * The Workspace basis, or nothing for a host that evaluates against none. - * - * The distinction is itself a ceiling. A run admitted with no Workspace and - * one admitted against a Workspace were granted different things, so a - * continuation that acquired or lost one is asking for a different grant - * rather than restating the same one. - */ - readonly workspace?: { readonly roots: readonly string[]; readonly selectedRoot: string }; /** * Every admitted spelling, in the order the selection resolved them: * composition first, then the read table, then the write table. */ readonly allowed: readonly RetainedEntry[]; - readonly requests: readonly FetchRequest[]; } /** The decision this run recorded, restored from its own durable record. */ @@ -1036,7 +991,6 @@ function selectedEntries( forms, kind: observation.kind ?? "capability", effect: "read", - ...(observation.requests === undefined ? {} : { requests: observation.requests }), }); } } @@ -1271,53 +1225,13 @@ function reads(content: string): boolean { return FRONTMATTER_REFERENCE.test(protectedEscapes) || BINDING_REFERENCE.test(protectedEscapes); } -/** Two normalized requests describing the same read. */ -function sameRequest(one: FetchRequest, other: FetchRequest): boolean { - return JSON.stringify(requestRecord(one)) === JSON.stringify(requestRecord(other)); -} - /** Two lists of the same strings, in the same order. */ function sameStrings(one: readonly string[], other: readonly string[]): boolean { return one.length === other.length && one.every((value, index) => value === other[index]); } -/** - * The requests each admitted observation may perform, normalized. - * - * Deliberately computed *outside* the durable admission. These are the host's - * own values, so a malformed one is a host mistake rather than a statement - * about the candidate — it should fail as itself, before anything is appended, - * rather than be serialized into the journal as a refusal of the fragment. - */ -function* normalizedCeilings(entries: readonly Entry[]): Operation { - const ceilings = new Map(); - for (const entry of entries) { - if (entry.requests === undefined) { - continue; - } - const normalized: FetchRequest[] = []; - for (const props of entry.requests) { - normalized.push(yield* prepareFetchRequest(props)); - } - // Keyed by the entry itself rather than by anything describing it: a name - // can hold two entries and only one of the two may perform a request, and - // an encoded key is only ever as unique as its own escaping. - ceilings.set(entry, normalized); - } - return ceilings; -} - -/** Each entry's own normalized ceiling, for the entries that perform requests. */ -type Ceilings = ReadonlyMap; - -/** The ceilings this run states, in the order the host stated them. */ -function currentPolicy( - request: GeneratedXmdRequest, - allow: readonly GeneratedEffectClass[], - entries: readonly Entry[], - ceilings: Ceilings, -): Policy { - const requests: FetchRequest[] = []; +/** The selected rows this run states, in the order the selection resolved them. */ +function currentPolicy(allow: readonly GeneratedEffectClass[], entries: readonly Entry[]): Policy { const allowed: RetainedEntry[] = []; for (const entry of entries) { // One row per admitted spelling, in canonical form order within the entry, @@ -1339,78 +1253,23 @@ function currentPolicy( effect: entry.effect, }); } - requests.push(...(ceilings.get(entry) ?? [])); } - const workspace = workspaceBasis(request); - return { - allow: [...allow], - ...(workspace === undefined ? {} : { workspace }), - allowed, - requests, - }; -} - -/** - * The Workspace basis this request states, or nothing. - * - * Both terms or neither: a host stating roots without the root its effects - * address, or the reverse, has stated half a basis, and half a ceiling is not - * one to admit a fragment under. - */ -function workspaceBasis( - request: GeneratedXmdRequest, -): { roots: readonly string[]; selectedRoot: string } | undefined { - const { workspaceRoots, selectedRoot } = request; - if (workspaceRoots === undefined && selectedRoot === undefined) { - return undefined; - } - if (workspaceRoots === undefined || selectedRoot === undefined) { - throw new GeneratedXmdError( - "a generated-XMD host stated half a Workspace basis. A host evaluates against a Workspace " + - "or against none, and the roots and the selected root are one statement.", - ); - } - // Validated here, as the host's own error, rather than compared later against - // a live basis and appearing to hold: a basis with no roots, a repeated root, - // or a selected root nothing retains is not a stricter grant than a coherent - // one — there is nothing for a continuation to be held to at all. - const roots = [...workspaceRoots]; - if (roots.length === 0 || roots.some((root) => root.length === 0)) { - throw new GeneratedXmdError("a generated-XMD host stated a Workspace basis retaining no root."); - } - if (new Set(roots).size !== roots.length) { - throw new GeneratedXmdError("a generated-XMD host stated one retained Workspace root twice."); - } - if (!roots.includes(selectedRoot)) { - throw new GeneratedXmdError( - "a generated-XMD host selected a Workspace root it does not retain.", - ); - } - return { roots, selectedRoot }; + return { allow: [...allow], allowed }; } /** * The policy as journal data, in the one closed shape this build has. * * It tags itself, which is what lets a record written by any other build be - * recognized as a shape this one does not have rather than guessed at, and it - * carries the Workspace basis as one optional member because an ordinary host - * has none — a reader that treated a missing basis as an empty one would - * compare a Workspace-less admission equal to a Workspace admission that had - * lost every root. + * recognized as a shape this one does not have rather than guessed at. What it + * holds is the selection and nothing live: a destination policy and an + * effective timeout are decided where the request is performed, so retaining + * them would compare a continuation against values that legitimately move. */ function policyRecord(policy: Policy): JsonObject { return { version: RECORD_VERSION, allow: [...policy.allow], - ...(policy.workspace === undefined - ? {} - : { - workspace: { - roots: [...policy.workspace.roots], - selectedRoot: policy.workspace.selectedRoot, - }, - }), allowed: policy.allowed.map((entry) => ({ name: entry.name, form: entry.form, @@ -1418,7 +1277,6 @@ function policyRecord(policy: Policy): JsonObject { kind: entry.kind, effect: entry.effect, })), - requests: policy.requests.map(requestRecord), }; } @@ -1467,106 +1325,15 @@ function readPolicy(value: Json): Policy | undefined { if (value.version !== RECORD_VERSION) { return undefined; } - if (!exactly(value, ["version", "allow", "allowed", "requests"], ["workspace"])) { - return undefined; - } - const workspace = Object.hasOwn(value, "workspace") ? readWorkspace(value.workspace) : undefined; - if (workspace === MALFORMED) { + if (!exactly(value, ["version", "allow", "allowed"])) { return undefined; } const classes = readClasses(value.allow); const allowed = readAllowed(value.allowed); - const requests = readRequests(value.requests); - if (classes === undefined || allowed === undefined || requests === undefined) { - return undefined; - } - return { - allow: classes, - ...(workspace === undefined ? {} : { workspace }), - allowed, - requests, - }; -} - -/** A record this version cannot read, told apart from one that holds nothing. */ -const MALFORMED = Symbol("malformed"); - -/** - * One Workspace basis, validated rather than merely well-typed. - * - * A basis with no roots, a repeated root, or a selected root the run does not - * retain is not a stricter grant than one without those faults — it is not a - * grant at all, because there is nothing coherent for a continuation to be held - * to. So it is refused here, before the admission, rather than compared later - * against a live basis and appearing to hold. - */ -function readWorkspace( - value: Json | undefined, -): { roots: readonly string[]; selectedRoot: string } | undefined | typeof MALFORMED { - if (!isJsonObject(value) || !exactly(value, ["roots", "selectedRoot"])) { - return MALFORMED; - } - const roots = readRoots(value.roots); - const { selectedRoot } = value; - if (roots === undefined || typeof selectedRoot !== "string") { - return MALFORMED; - } - return roots.includes(selectedRoot) ? { roots, selectedRoot } : MALFORMED; -} - -/** The retained roots, which are a non-empty set rather than a list. */ -function readRoots(value: Json | undefined): readonly string[] | undefined { - if (!Array.isArray(value) || value.length === 0) { + if (classes === undefined || allowed === undefined) { return undefined; } - const roots: string[] = []; - for (const root of value) { - if (typeof root !== "string" || root.length === 0 || roots.includes(root)) { - return undefined; - } - roots.push(root); - } - return roots; -} - -/** - * The retained requests, read closed. - * - * A reader that ignored members it does not know, or treated a malformed - * `timeout` as an absent one, would let a retained ceiling compare equal to a - * live one it does not describe, so this admits exactly the declared members. - */ -function readRequests(value: Json): FetchRequest[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const requests: FetchRequest[] = []; - for (const entry of value) { - if (!isJsonObject(entry) || !exactly(entry, ["url", "method", "headers"], ["timeout"])) { - return undefined; - } - const { url, method, headers, timeout } = entry; - if (typeof url !== "string" || typeof method !== "string" || !isJsonObject(headers)) { - return undefined; - } - if (Object.hasOwn(entry, "timeout") && typeof timeout !== "number") { - return undefined; - } - const named: Record = {}; - for (const [header, value] of Object.entries(headers)) { - if (typeof value !== "string") { - return undefined; - } - named[header] = value; - } - requests.push({ - url, - method, - headers: named, - ...(typeof timeout === "number" ? { timeout } : {}), - }); - } - return requests; + return { allow: classes, allowed }; } /** The selected classes, which are a non-empty set in canonical order. */ @@ -1662,78 +1429,26 @@ function readNamed(value: Json): RetainedInvocation[] | undefined { * Whether a resumed run still holds the grant the retained admission was made * under. * - * Two kinds of term, compared differently on purpose. - * - * The Workspace roots are as-of-admission provenance over a set the run's own - * progress legitimately grows: every committed mutation retains another - * immutable root and advances the authoritative current root. So the retained - * basis is asked for by membership — every admission root and the admission's - * selected root must still be retained, and the root the run now stands on - * must be a retained one — while additional roots and an advanced current - * root change nothing this admission was granted under. A run that lost an - * admission root no longer holds the history the grant was made over, and is - * refused before any generated work. - * - * Every other term is a host-stated ceiling and compares whole and exactly, - * in order: a widened class selection, one identity behind a spelling replaced, - * a form added, a spelling moved into the other effect table, or one request - * added to the allowed set each make this false. Widening is the case that - * matters most — a ceiling that still contains the original request is + * Every term compares whole and exactly, in order: a widened class selection, + * one identity behind a spelling replaced, a form added, or a spelling moved + * into the other effect table each make this false. Widening is the case that + * matters most — a selection that still holds the original spelling is * precisely the one that comparing the *fragment* against the *current* policy * would wave through. */ function policyHolds(retained: Policy, current: Policy): boolean { - if (!workspaceHolds(retained.workspace, current.workspace)) { - return false; - } if (!sameStrings(retained.allow, current.allow)) { return false; } if (retained.allowed.length !== current.allowed.length) { return false; } - const replaced = retained.allowed.some((entry, index) => { + return retained.allowed.every((entry, index) => { const here = current.allowed[index]; - return here === undefined || !sameRow(entry, here); - }); - if (replaced) { - return false; - } - if (retained.requests.length !== current.requests.length) { - return false; - } - return retained.requests.every((request, index) => { - const here = current.requests[index]; - return here !== undefined && sameRequest(request, here); + return here !== undefined && sameRow(entry, here); }); } -/** - * Whether a resumed run still holds the Workspace basis it was admitted over. - * - * Absent on both sides is a host that evaluates against no Workspace, and two - * of those hold each other. Present on one side only is a run that acquired or - * lost a Workspace between the admission and the resume, which is a different - * grant rather than the same one restated. - * - * Present on both compares by membership, because the set legitimately grows: - * every committed mutation retains another immutable root and advances the - * authoritative current one. So each admission root and the admission's - * selected root must still be retained, and the root the run now stands on must - * be a retained one — while additional roots change nothing this admission was - * granted under. - */ -function workspaceHolds(retained: Policy["workspace"], current: Policy["workspace"]): boolean { - if (retained === undefined || current === undefined) { - return retained === current; - } - const held = new Set(current.roots); - if (!retained.roots.every((root) => held.has(root))) { - return false; - } - return held.has(retained.selectedRoot) && held.has(current.selectedRoot); -} - /** One element the fragment named, and the entry preflight selected for it. */ interface Planned extends RetainedInvocation { readonly entry: Entry; @@ -1748,23 +1463,20 @@ interface Preflight { /** * Walk the complete fragment, admitting it or refusing it whole. * - * Nothing here performs an effect. The requests are normalized — the candidate's - * and the host's alike — because deciding whether two requests are the same one - * is what `prepareFetchRequest()` is for, and a second reading of the same props - * could disagree with the one `` will make. + * Nothing here performs an effect, and nothing here decides a destination. What + * the fragment may *name* is settled from its own language — the constructs, + * the names, the forms, the props and the effect classes — while what an + * admitted `` may actually reach is the live terminal's question, + * asked of the transformed request immediately before transport. So a dynamic + * destination the fragment's data expressions produce is an ordinary input + * here rather than something preflight has to resolve. */ +// deno-lint-ignore require-yield function* preflight( id: string, source: string, table: ReadonlyMap, - ceilings: Ceilings, ): Operation { - // Read once, here, so a contextual default that refuses fails as itself - // rather than as a statement about the fragment. Every candidate request - // below reads the same value, and what it can fail on afterwards is the - // props it was handed. - yield* timeoutFetch; - const named: Planned[] = []; // Scanned as the fragment it is: generated text has no path, so every // executable position in it names this admission instead. That is what lets a @@ -1774,28 +1486,10 @@ function* preflight( const segments = scanSegments(source, { generatedSource: id, baseOffset: 0, baseLine: 1 }); // A generated fragment starts with no bindings: it imports none from the // document that admitted it, and exports none back to it. - yield* walk(segments, table, ceilings, named, new Set()); + yield* walk(segments, table, named, new Set()); return { segments, named }; } -/** - * The candidate's request, normalized — or the fixed refusal. - * - * `prepareFetchRequest()` reports what is wrong with a request by quoting it: - * the URL that is not a URL, the header name written twice, the timeout that is - * not a duration. Every one of those is generated text, and a refusal of - * generated text may not carry it — not into the run's failure, and not into - * the journal, where a thrown executor error is serialized with its message and - * its stack. So the diagnostic is dropped here and the class is kept. - */ -function* admitCandidateRequest(props: Record): Operation { - try { - return yield* prepareFetchRequest(props); - } catch { - throw new Refusal("request"); - } -} - /** * Whole-fragment preflight, over the scope the fragment will actually have. * @@ -1811,7 +1505,6 @@ function* admitCandidateRequest(props: Record): Operation, - ceilings: Ceilings, named: Planned[], scope: Set, lexical: Lexical = ROOT_LEXICAL, @@ -1834,7 +1527,7 @@ function* walk( // table lookup would be refused as a component the host withheld, which // is a different and misleading thing to tell a candidate. if (RESERVED_STRUCTURAL.has(segment.name)) { - yield* structural(segment, table, ceilings, named, scope, lexical); + yield* structural(segment, table, named, scope, lexical); break; } const entries = table.get(segment.name); @@ -1873,13 +1566,6 @@ function* walk( if (entry === undefined) { throw new Refusal(form === "paired" ? "content" : "form"); } - if (entry.requests !== undefined) { - const ceiling = ceilings.get(entry) ?? []; - const candidate = yield* admitCandidateRequest(segment.props); - if (!ceiling.some((allowed) => sameRequest(allowed, candidate))) { - throw new Refusal("request"); - } - } // The identity this spelling runs under, from the entry's own // definition. The form was decided from the scan just above, which is // what separates `` from `…`. @@ -1893,7 +1579,7 @@ function* walk( // body rather than a transparent region, so the lexical facts reset: // a `` written in a component's own body cannot break a loop // that encloses the invocation. - yield* walk(segment.children, table, ceilings, named, new Set(scope), ROOT_LEXICAL); + yield* walk(segment.children, table, named, new Set(scope), ROOT_LEXICAL); // After the element, because a component's result does not exist until // it has run: `` beside `` is ordered, // and `` names nothing. @@ -1991,7 +1677,6 @@ const NO_GENERATED_CONTEXT: ReadonlySet = new Set(["Content", "Output", function* structural( segment: ComponentElement, table: ReadonlyMap, - ceilings: Ceilings, named: Planned[], scope: Set, lexical: Lexical, @@ -2025,7 +1710,6 @@ function* structural( yield* alternatives( [{ body: structure.whenTrue }, { body: structure.whenFalse }], table, - ceilings, named, scope, lexical, @@ -2044,7 +1728,6 @@ function* structural( yield* alternatives( branches.map((branch) => ({ matcher: branch.element, body: branch.element.children })), table, - ceilings, named, scope, lexical, @@ -2056,7 +1739,7 @@ function* structural( refuseStructure(loopViolations(segment)); // Inside for the body alone: a `` after the loop is as stray as one // written where no loop ever was. - yield* transparent(segment.children, table, ceilings, named, scope, { + yield* transparent(segment.children, table, named, scope, { ...lexical, insideLoop: true, }); @@ -2074,14 +1757,14 @@ function* structural( // above already refused a `` or `` that would cross it. const incoming: ReadonlySet = new Set(scope); for (const spawn of structure.spawns) { - yield* walk(spawn.element.children, table, ceilings, named, new Set(incoming), ROOT_LEXICAL); + yield* walk(spawn.element.children, table, named, new Set(incoming), ROOT_LEXICAL); } return; } if (name === "PrintErrors") { refuseStructure(printErrorsViolations(segment)); - yield* transparent(segment.children, table, ceilings, named, scope, lexical); + yield* transparent(segment.children, table, named, scope, lexical); return; } @@ -2096,20 +1779,20 @@ function* structural( if (child.type === "component" && child.name === "Answer") { refuseStructure(answerViolations(child)); stated(child, scope); - yield* transparent(child.children, table, ceilings, named, scope, lexical); + yield* transparent(child.children, table, named, scope, lexical); continue; } if (!isBlankText(child)) { body.push(child); } } - yield* transparent(body, table, ceilings, named, scope, lexical); + yield* transparent(body, table, named, scope, lexical); return; } if (name === "Let") { refuseStructure(letViolations(segment)); - yield* transparent(segment.children, table, ceilings, named, scope, lexical); + yield* transparent(segment.children, table, named, scope, lexical); // After its own body, like a component's `as`: a `` cannot name the // binding it is producing. const bound = letBindingName(segment); @@ -2133,7 +1816,7 @@ function* structural( // caller is the one capture it was asked for. const body = new Set(scope); body.add(item); - yield* walk(segment.children, table, ceilings, named, body, lexical); + yield* walk(segment.children, table, named, body, lexical); const capture = eachCaptureBinding(segment); if (capture !== undefined) { scope.add(capture); @@ -2173,7 +1856,6 @@ interface Alternative { function* alternatives( branches: readonly Alternative[], table: ReadonlyMap, - ceilings: Ceilings, named: Planned[], scope: Set, lexical: Lexical, @@ -2185,7 +1867,7 @@ function* alternatives( stated(branch.matcher, incoming); } const region = new Set(incoming); - yield* walk(branch.body, table, ceilings, named, region, lexical); + yield* walk(branch.body, table, named, region, lexical); for (const bound of region) { if (!incoming.has(bound)) { outgoing.add(bound); @@ -2210,13 +1892,12 @@ function* alternatives( function* transparent( segments: readonly Segment[], table: ReadonlyMap, - ceilings: Ceilings, named: Planned[], scope: Set, lexical: Lexical, ): Operation { const region = new Set(scope); - yield* walk(segments, table, ceilings, named, region, lexical); + yield* walk(segments, table, named, region, lexical); for (const bound of region) { scope.add(bound); } @@ -2240,13 +1921,14 @@ const GENERATED_XMD = "generated_xmd"; * The one record shape this build writes and reads. * * A fixed marker rather than a version to negotiate: every record carries it, - * and a record carrying anything else — the untagged #369 shape, or the tagged - * one whose identities were structural records of an origin, a key and a - * revision — is a shape whose rules this build does not know. Such a record is - * refused as unreadable history, before the admission it would have restored - * performs anything, rather than being migrated or read in part. + * and a record carrying anything else — the untagged #369 shape, the tagged one + * whose identities were structural records of an origin, a key and a revision, + * or the marker-3 one carrying a Workspace basis and a request inventory — is a + * shape whose rules this build does not know. Such a record is refused as + * unreadable history, before the admission it would have restored performs + * anything, rather than being migrated or read in part. */ -const RECORD_VERSION = 3; +const RECORD_VERSION = 4; /** * What the durable admission records for this source. @@ -2260,11 +1942,10 @@ function* admitSource( id: string, source: string, table: ReadonlyMap, - ceilings: Ceilings, policy: Policy, ): Operation { try { - const { named } = yield* preflight(id, source, table, ceilings); + const { named } = yield* preflight(id, source, table); return parseJson({ version: RECORD_VERSION, decision: "admitted", @@ -2298,7 +1979,6 @@ function* persistAdmission( id: string, source: string, table: ReadonlyMap, - ceilings: Ceilings, policy: Policy, position: Readonly | undefined, ): Operation { @@ -2309,7 +1989,7 @@ function* persistAdmission( input: policyRecord(policy), ...sourceDescription(position), }, - () => admitSource(id, source, table, ceilings, policy), + () => admitSource(id, source, table, policy), ); return parseJson(stored); } @@ -2470,14 +2150,12 @@ export function* evaluateProtectedGeneratedXmd( const allow = selection(request.allow); const entries = selectedEntries(request, allow); const table = admitted(entries); - const ceilings = yield* normalizedCeilings(entries); - const policy = currentPolicy(request, allow, entries, ceilings); + const policy = currentPolicy(allow, entries); const stored = yield* persistAdmission( request.id, request.source, table, - ceilings, policy, request.position, ); @@ -2496,16 +2174,14 @@ export function* evaluateProtectedGeneratedXmd( CONSTRUCT[decided.construct], ); } - // Before a single component is invoked or a single request is performed: a - // retained admission is a grant whose non-root ceilings must be stated - // exactly again, and whose Workspace basis must still be retained. The run's - // own progress may have retained further roots and advanced the current one; - // a run that lost an admission root, or moved any exact term, is asking for - // a different grant. + // Before a single component is invoked or a single effect is performed: a + // retained admission is a grant whose selected spellings, identities, + // effects and classes must be stated exactly again. A run that moved any of + // them is asking for a different grant. if (!policyHolds(decided.policy, policy)) { throw new GeneratedXmdError(CEILING); } - // And for the exact text, on the same terms as the ceilings. An admission is + // And for the exact text, on the same terms as the selection. An admission is // a decision about one fragment; a caller now holding a different one is // asking for a decision that was never made, so it refuses here rather than // quietly expanding the retained copy in its place. @@ -2515,6 +2191,6 @@ export function* evaluateProtectedGeneratedXmd( // The retained source is what expands, so a continuation runs exactly the // bytes this run admitted rather than a caller's copy of them. - const restored = yield* preflight(request.id, decided.source, table, ceilings); + const restored = yield* preflight(request.id, decided.source, table); return yield* expand(request.id, restored.segments, restored.named, componentRouting, syntax); } diff --git a/packages/core/src/syntax-admitted.ts b/packages/core/src/syntax-admitted.ts index e7ab1cc9e..f4a4b976a 100644 --- a/packages/core/src/syntax-admitted.ts +++ b/packages/core/src/syntax-admitted.ts @@ -3,8 +3,8 @@ * * An agent asked to write a fragment is told what a fragment may contain. That * is not the vocabulary of the site the `` element was written at: - * the evaluator admits the trusted composition table and the host's pinned - * identities for the selected classes, and nothing else — no executable code + * the evaluator admits the trusted composition table and the names the host + * selected for the selected classes, and nothing else — no executable code * block, no interpolation, and none of the structural constructs an ordinary * document has. * @@ -22,7 +22,6 @@ import { protectedComponent } from "./components/protected.ts"; import type { CapturedEntry } from "./evaluation-profile.ts"; -import type { FragmentCapability } from "./fragment-capabilities.ts"; import type { CompleteComponentSyntaxEntry, SyntaxSymbols } from "./inspect.ts"; import type { ReturnsSchema } from "./types.ts"; @@ -30,53 +29,35 @@ import type { ReturnsSchema } from "./types.ts"; const UNDECLARED: ReturnsSchema = { type: "string" }; /** - * The component canonical core owns that performs each of these operations. - * - * A capability is core's operation under the name the host chose, so neither - * half alone identifies a component: a host may admit `file:write` as - * ``, and nothing stops it admitting `files:glob` as ``. The - * pair does. When a host admits the operation core's own component of that name - * performs, the component an author was shown under that name is the one they - * may run here — and availability is exactly that comparison, so reporting this - * entry as an ordinary registration would answer `no` for a component the - * fragment can write. - * - * Only the operations core owns a component for appear here. A read and a write - * both belong to ``, because one name holds both forms. - */ -const OWNED_CAPABILITIES: Readonly>> = Object.freeze({ - "file:read": "File", - "file:write": "File", - "file:delete": "File.Delete", - "directory:ensure": "Dir", -}); - -/** - * Core's documentation origin for this entry, when the entry is core's own - * component however the host admitted it. + * Core's documentation origin for this entry, when canonical core owns the name. * * Documentation provenance only. It changes nothing a continuation compares: * what the grant was recorded as is the identity the owning declaration offers * the admitted spelling under, and that is core's wherever core supplies the * component behind the name. + * + * Two sources, in order. An exact routed answer carries its own canonical + * origin, which survives a provider delegating it. Otherwise a name in the + * canonical protected tier is core's by construction — nothing else can answer + * for it — so reporting it as an ordinary registration would answer "could I + * supply this name myself?" with yes about a name no host can take. */ function ownedOrigin(entry: CapturedEntry): string | undefined { if (entry.protectedOrigin !== undefined) { return entry.protectedOrigin; } - const owned = entry.capability === undefined ? undefined : OWNED_CAPABILITIES[entry.capability]; - return owned === entry.name ? protectedComponent(entry.name)?.origin : undefined; + return protectedComponent(entry.name)?.origin; } /** * The symbols an evaluation admitted, in the order the tables state them. * * Each entry reports the origin its implementation came from: core's own where - * core supplies the body, and the publishing provider installation's where the - * import chain answered. Reported as a registration for ordinary entries. An - * exact protected answer keeps its canonical documentation origin even when a - * different provider delegates it, and so does a capability performing an - * operation canonical core owns a component for, under that component's name. + * canonical resolution answered with core's, and the publishing provider + * installation's where a provider answered. Reported as a registration for + * ordinary entries, and as protected for a name the canonical protected tier + * owns — including an exact protected answer a different provider delegated, + * which keeps its canonical documentation origin. */ export function admittedSymbols(entries: readonly CapturedEntry[]): SyntaxSymbols { return { @@ -110,10 +91,9 @@ function describe(entry: CapturedEntry): CompleteComponentSyntaxEntry { captures: definition.captures === undefined ? [] : [...definition.captures], returnMode: definition.returns === undefined ? "text" : "value", returns: definition.returns ?? UNDECLARED, - // The host's own prose about the admitted entry, not the capability body's. - // Core supplies the operation; only the host knows what admitting it under - // this name means here, and a catalog that named a component and said - // nothing about it would tell an agent a name and no more. + // The owning declaration's own prose. A host selects a name and says + // nothing about it, so what describes an admitted entry is what describes + // the component everywhere else. ...(entry.description === undefined ? {} : { description: entry.description }), // `as` and `context` are deliberately absent whatever the implementation // declares, for two different reasons. Binding is engine-owned language diff --git a/packages/core/tests/all.test.ts b/packages/core/tests/all.test.ts index 4d456d970..9a2248cfb 100644 --- a/packages/core/tests/all.test.ts +++ b/packages/core/tests/all.test.ts @@ -32,13 +32,9 @@ import { useHostFiles } from "@executablemd/runtime"; import { useEchoExec, useStubFs } from "@executablemd/runtime/test"; import { execute } from "../src/execute.ts"; import { executeInstalled } from "../host.ts"; -import { evaluateGeneratedXmd, pinnedComponent } from "../host.ts"; -import type { - DurablePreparation, - ExecutionInstallation, - GeneratedObservation, - GeneratedXmdRequest, -} from "../host.ts"; +import { evaluateGeneratedXmd, pinnedComponent } from "../src/generated-xmd.ts"; +import type { GeneratedObservation, GeneratedXmdRequest } from "../src/generated-xmd.ts"; +import type { DurablePreparation, ExecutionInstallation } from "../host.ts"; import { forEach } from "@effectionx/stream-helpers"; import { collect } from "../src/collect.ts"; import { Component, retain } from "../src/component-api.ts"; @@ -1257,13 +1253,7 @@ function generatedProbe(): GeneratedObservation { } function generatedRequest(source: string): GeneratedXmdRequest { - return { - id: "turn-1", - source, - workspaceRoots: ["workspace://primary"], - selectedRoot: "workspace://primary", - observations: [generatedProbe()], - }; + return { id: "turn-1", source, observations: [generatedProbe()] }; } interface Attempt { diff --git a/packages/core/tests/evaluate-component.test.ts b/packages/core/tests/evaluate-component.test.ts index cd06b9d94..2259d4e96 100644 --- a/packages/core/tests/evaluate-component.test.ts +++ b/packages/core/tests/evaluate-component.test.ts @@ -17,24 +17,16 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, Json } from "@executablemd/durable-streams"; -import { ensure, Err, Ok, scoped, spawn, suspend, withResolvers } from "effection"; +import { ensure, Err, Ok, scoped, spawn, suspend, useScope, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import { API } from "@executablemd/runtime"; import { collect } from "../src/collect.ts"; import { Component, content } from "../src/component-api.ts"; import { executeInstalled, generatedRequestRefusal } from "../host.ts"; -import { - directoryEntry, - fileDeleteEntry, - fileReadEntry, - fileWriteEntry, - globReadEntry, - syntaxReadEntry, -} from "../host.ts"; -import type { ExecutionInstallation, FragmentEvaluationInput } from "../host.ts"; +import type { ExecutionInstallation } from "../host.ts"; +import { useSelection } from "./support/generated-selection.ts"; import { registerComponents } from "../src/components/registration.ts"; -import { elicitWriteEntry } from "../host.ts"; import { useSyntax } from "../host.ts"; import { Elicitation } from "../src/elicitation-api.ts"; import type { ElicitationRequest } from "../src/elicitation-api.ts"; @@ -47,7 +39,10 @@ import type { FunctionComponentDefinition } from "../src/types.ts"; import { ActiveProjection } from "../src/projection.ts"; import { installIdentities } from "../src/invocation-identity.ts"; import { SYNTAX_PROTECTED } from "../src/components/Syntax.ts"; -import { prepareEvaluationProfile } from "../src/evaluation-profile.ts"; +import { captureEvaluationProfile, evaluationLifetime } from "../src/evaluation-profile.ts"; +import type { ResolvedAnswer, ResolvedAnswers } from "../src/evaluation-profile.ts"; +import { coreComponentDefinition, CORE_ORIGIN } from "../src/components/registry.ts"; +import { fixedFilesystemDefinition } from "../src/components/fixed-filesystem.ts"; import { admittedSymbols } from "../src/syntax-admitted.ts"; function independentWrapper(answer: FunctionComponentDefinition): FunctionComponentDefinition { @@ -66,8 +61,8 @@ function syntaxProfile( ): ExecutionInstallation { let captured = false; return { - evaluation: { - read: [{ kind: "component-answer", name: "Syntax", forms: ["self-closing"] }], + *install(): Operation { + yield* useSelection({ Syntax: ["self-closing"] }); }, componentAnswers: [ { @@ -91,23 +86,43 @@ function syntaxProfile( }; } +/** + * The answers canonical resolution gives for core's own filesystem and search + * names, as capture receives them. + * + * Capture resolves nothing itself: the chain has already answered by the time + * it runs, and what it is handed is core's own copy. These rows hand it core's + * definitions directly, which is the seam. + */ +function coreFilesystemAnswers(...names: readonly string[]): ResolvedAnswers { + const answers = new Map(); + for (const name of names) { + const definition = fixedFilesystemDefinition(name) ?? coreComponentDefinition(name); + if (definition === undefined) { + throw new Error(`core supplies no ${name} component`); + } + answers.set(name, { definition, origin: CORE_ORIGIN }); + } + return answers; +} + describe("protected generated component answers", () => { it("routes delegated Syntax through both wrappers and renders its ordinary output", function* () { const profile = syntaxProfile(); - if (profile.evaluation === undefined) { - throw new Error("expected a Syntax evaluation profile"); - } // syntax_symbols retains rendered text, so sourceKind must be checked on - // the sealed profile's structured symbols before rendering drops that field. + // the captured profile's structured symbols before rendering drops that + // field. const identity = installIdentities([], [], [SYNTAX_PROTECTED]); - const prepared = yield* prepareEvaluationProfile(profile.evaluation); + const lifetime = evaluationLifetime(); try { const definition = identity.protected.get("Syntax"); if (definition === undefined) { throw new Error("expected canonical Syntax"); } - const captured = yield* prepared.seal( + const captured = captureEvaluationProfile( + new Map([["Syntax", ["self-closing"]]]), new Map([["Syntax", { definition, origin: "@executablemd/core" }]]), + lifetime, identity.componentRouting.project, ); const symbols = admittedSymbols(captured.read); @@ -119,7 +134,7 @@ describe("protected generated component answers", () => { }, ]); } finally { - prepared.revoke(); + lifetime.revoke(); identity.identities.revoke(); } const stream = new InMemoryStream(); @@ -150,24 +165,27 @@ describe("protected generated component answers", () => { }); /** - * An admitted capability for an operation canonical core owns a component - * for is that component, however the host identified its grant. + * A selected name canonical core protects is reported as the component it is. * * Availability compares what an author was *shown* with what this evaluation * may *run*, by identity rather than by spelling. The enclosing reference - * documents core's own ``, so an admitted entry reporting an ordinary - * registration is a different component under the same name — and a fragment - * admitted to write a file would be told it could not. What the host decides - * is whether the name is admitted at all; the identity the grant is recorded - * under is core's, from the declaration that owns the component. + * documents core's own ``, so a selected entry reporting an ordinary + * registration would be a different component under the same name — and a + * fragment admitted to write a file would be told it could not. What the host + * chooses is whether the name is selected at all; the identity the grant is + * recorded under is core's, from the declaration that owns the component. */ - it("reports an admitted file operation as the component canonical core owns", function* () { - const prepared = yield* prepareEvaluationProfile({ - read: [fileReadEntry()], - write: [directoryEntry("Dir")], - }); + it("reports a selected filesystem name as the component canonical core owns", function* () { + const lifetime = evaluationLifetime(); try { - const captured = yield* prepared.seal(new Map()); + const captured = captureEvaluationProfile( + new Map([ + ["File", ["self-closing"]], + ["Dir", ["paired"]], + ]), + coreFilesystemAnswers("File", "Dir"), + lifetime, + ); expect(admittedSymbols(captured.read).categories[2].entries).toMatchObject([ { name: "File", @@ -189,40 +207,35 @@ describe("protected generated component answers", () => { paired: { identity: "https://executable.md/components/dir/ensure", effect: "write" }, }); } finally { - prepared.revoke(); + lifetime.revoke(); } + yield* useScope(); }); /** - * And the pairing is what carries it, not the name. + * And a replaceable name is reported as the registration it resolves to. * - * A host chooses the name a capability is admitted under, so the name alone - * says nothing about which component is behind it. An operation core owns no - * component for stays an ordinary registration even under a name core does - * own — otherwise this would be telling an author that the `` they read - * about is the thing this fragment may run. + * A selected name is resolved through the ordinary import chain, so what is + * behind one is whatever that chain answered with. `` is a core default + * rather than a protected name, so the catalog says `registered` — which is + * the honest answer to "could I supply this name myself?". */ - it("reports a differently named or different operation as the registration it is", function* () { - const prepared = yield* prepareEvaluationProfile({ - // The operation core owns `` for, under a name it does not: a host - // may call its own grant whatever it likes. - read: [ - { ...fileReadEntry(), name: "Scribe" }, - // And the other half: an operation core owns no `` body for, - // admitted under the name it does own. - { ...globReadEntry(), name: "File" }, - ], - }); + it("reports a replaceable selected name as the registration it is", function* () { + const lifetime = evaluationLifetime(); try { - const captured = yield* prepared.seal(new Map()); + const captured = captureEvaluationProfile( + new Map([["Glob", ["self-closing"]]]), + coreFilesystemAnswers("Glob"), + lifetime, + ); const entries = admittedSymbols(captured.read).categories[2].entries; expect(entries.map((entry) => `${entry.name}: ${entry.sourceKind}`)).toEqual([ - "Scribe: registered", - "File: registered", + "Glob: registered", ]); } finally { - prepared.revoke(); + lifetime.revoke(); } + yield* useScope(); }); it("keeps the producer's route while the generated child admits only Syntax", function* () { @@ -275,7 +288,7 @@ describe("protected generated component answers", () => { { name: "copied", options: { transform: (answer) => ({ ...answer }) }, - reason: "no provider published", + reason: "nor one a provider published", }, { name: "replaced body", @@ -288,12 +301,12 @@ describe("protected generated component answers", () => { }, }), }, - reason: "no provider published", + reason: "nor one a provider published", }, { name: "wrapped", options: { transform: independentWrapper }, - reason: "no provider published", + reason: "nor one a provider published", }, { name: "mutated", @@ -303,7 +316,7 @@ describe("protected generated component answers", () => { return answer; }, }, - reason: "no provider published", + reason: "nor one a provider published", }, ]; for (const { name, options, reason } of variants) { @@ -390,7 +403,7 @@ describe("protected generated component answers", () => { yield* refusal( run(source, [syntaxProfile({ transform: () => held })], new InMemoryStream(partial)), ), - ).toContain("no provider published"); + ).toContain("nor one a provider published"); function inspect(value: unknown): void { expect(typeof value).not.toBe("function"); if (typeof value === "object" && value !== null) { @@ -413,19 +426,27 @@ const ROOT_PATH = "evaluate.md"; const NOTE = "the retained note\n"; -/** A read-only profile over one recorder. */ +/** A read-only selection over one recorder. */ function reading(files: RecordedFiles): ExecutionInstallation { - return { evaluation: { read: [fileReadEntry()] }, install: () => useRecordedFiles(files) }; + return { + *install(): Operation { + yield* useSelection({ File: ["self-closing"] }); + yield* useRecordedFiles(files); + }, + }; } -/** A profile offering both classes over one recorder. */ +/** A selection whose spellings reach both classes, over one recorder. */ function both(files: RecordedFiles): ExecutionInstallation { return { - evaluation: { - read: [fileReadEntry()], - write: [fileWriteEntry(), directoryEntry("Dir"), fileDeleteEntry()], + *install(): Operation { + yield* useSelection({ + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + Dir: ["paired"], + }); + yield* useRecordedFiles(files); }, - install: () => useRecordedFiles(files), }; } @@ -1002,20 +1023,33 @@ describe("Tier FE — allow selects, and never adds", () => { }); }); -describe("Tier FE — an execution with no ceiling has no evaluation", () => { - it("FE5: a host that stated no profile refuses at the element, not at startup", function* () { +describe("Tier FE — an execution with no selection has no evaluation", () => { + it("FE5: a host that selected nothing refuses at the element, not at startup", function* () { expect(String(yield* run(`nothing asked for\n`, []))).toContain("nothing asked for"); expect(yield* refusal(run(`\n`, []))).toContain( - "this host stated none", + "this host selected none", ); }); - it("FE5: two installations stating a profile is refused before anything installs", function* () { - const files = recordedFiles(); - expect(yield* refusal(run(`nothing asked for\n`, [reading(files), reading(files)]))).toContain( - "one maximum permission", + /** + * Two handlers compose; they are not a setup error. + * + * This row used to assert the opposite, because a profile was an installation + * member and two of them were two answers to one question with no way to + * choose between them. A selection is one contextual value, so normal + * composition answers it: the nearer handler decides, the chain is what a + * host assembled, and there is no contributor count to refuse on. + */ + it("FE5: two handlers offering a selection compose rather than refusing", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const output = yield* run( + `\\n'} as="answer" />\n\n\n`, + [reading(files), reading(files)], ); + + expect(String(output)).toContain("the retained note"); + expect(files.performed).toEqual(["read notes.md"]); }); /** @@ -1034,7 +1068,7 @@ describe("Tier FE — an execution with no ceiling has no evaluation", () => { const files = recordedFiles({ "notes.md": NOTE }); const output = yield* run( `\\n'} as="answer" />\n\n\n`, - [{ evaluation: { read: [fileReadEntry()] }, install: () => useRecordedFiles(files) }], + [reading(files)], ); expect(String(output)).toContain("the retained note"); @@ -1214,8 +1248,10 @@ describe("Tier FE — one occurrence, one durable decision", () => { // restated. const second = recordedFiles({ "notes.md": NOTE }); const widened: ExecutionInstallation = { - evaluation: { read: [fileReadEntry(), globReadEntry()] }, - install: () => useRecordedFiles(second), + *install(): Operation { + yield* useSelection({ File: ["self-closing"], Glob: ["self-closing"] }); + yield* useRecordedFiles(second); + }, }; const failed = yield* refusal(run(source, [widened], new InMemoryStream(partial))); @@ -1652,11 +1688,15 @@ describe("Tier FE34 — the shared read profile", () => { /** The read table `xmd run` states, over one recorder. */ function shared(files: RecordedFiles): ExecutionInstallation { return { - evaluation: { - read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], - write: [fileWriteEntry(), fileDeleteEntry()], + *install(): Operation { + yield* useSelection({ + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + Glob: ["self-closing"], + Syntax: ["self-closing"], + }); + yield* useRecordedFiles(files); }, - install: () => useRecordedFiles(files), }; } @@ -1787,15 +1827,16 @@ describe("Tier FE34 — the shared read profile", () => { `\\n'} as="answer" />\n\n\n`, [ { - evaluation: { read: [globReadEntry()] }, - install: () => - useRecordedFiles({ + *install(): Operation { + yield* useSelection({ Glob: ["self-closing"] }); + yield* useRecordedFiles({ ...files, *globFiles(input) { searched.push(input.cwd); return yield* files.globFiles(input); }, - }), + }); + }, }, ], ); @@ -1881,7 +1922,7 @@ describe("Tier FE34 — the shared read profile", () => { // position, on the exact object materialization produced. A handler that // returns anything else returns something nobody published. expect(seen.length).toBeGreaterThan(0); - expect(failed).toContain("no provider published"); + expect(failed).toContain("nor one a provider published"); expect(files.performed).toEqual([]); }); @@ -2006,15 +2047,20 @@ describe("Tier FE34 — the shared read profile", () => { try { yield* run(`\\n'} />\n`, [ { - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, - install: () => - useRecordedFiles({ + *install(): Operation { + yield* useSelection({ + File: ["self-closing"], + Glob: ["self-closing"], + Syntax: ["self-closing"], + }); + yield* useRecordedFiles({ ...broken, // deno-lint-ignore require-yield *readTextFile(): Operation { throw new Error("the Files provider failed"); }, - }), + }); + }, }, ]); } catch (error) { @@ -2043,15 +2089,15 @@ describe("Tier FE34 — the shared read profile", () => { }, }; const profile: ExecutionInstallation = { - evaluation: { - read: [ - fileReadEntry(), - globReadEntry(), - syntaxReadEntry(), - { kind: "component-answer", name: "Held", forms: ["self-closing"] }, - ], + *install(): Operation { + yield* useSelection({ + File: ["self-closing"], + Glob: ["self-closing"], + Syntax: ["self-closing"], + Held: ["self-closing"], + }); + yield* useRecordedFiles(files); }, - install: () => useRecordedFiles(files), componentAnswers: [answerProvider("Held", held)], }; @@ -2092,24 +2138,24 @@ describe("Tier FE34 — the shared read profile", () => { expect(generatedRequestRefusal(classified)).toContain("NoSuchComponent"); }); - it("FE34: core answers for Syntax alone, so Evaluate cannot be admitted as an answer", function* () { + it("FE34: `` offers no generated form, so it cannot admit itself", function* () { const files = recordedFiles({ "notes.md": NOTE }); const failed = yield* refusal( run(`\\n'} />\n`, [ { - evaluation: { - read: [{ kind: "component-answer", name: "Evaluate", forms: ["self-closing"] }], + *install(): Operation { + yield* useSelection({ Evaluate: ["self-closing"] }); + yield* useRecordedFiles(files); }, - install: () => useRecordedFiles(files), }, ]), ); - // Core publishing an answer is what makes a protected name admissible, and - // it publishes one for the vocabulary and nothing else: evaluation inside - // evaluation is a different grant from being shown what a fragment may - // write. The hand-written entry resolves to an answer nobody published. - expect(failed).toContain("no provider published"); + // Canonical resolution answers for `Evaluate` — it is a protected name — + // and its declaration offers no generated use of either spelling. So + // evaluation inside evaluation is refused where the selection is captured: + // being shown a vocabulary is a different grant from being able to open one. + expect(failed).toContain("offers no generated use of it"); expect(files.performed).toEqual([]); }); }); @@ -2134,14 +2180,14 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off const OPEN = `\\n'} as="answer" />\n\n\n`; /** The entry a host states for a provider-backed name. */ - function admits(): FragmentEvaluationInput { - return { read: [{ kind: "component-answer", name: "Open", forms: ["self-closing"] }] }; + function admits(): Operation { + return useSelection({ Open: ["self-closing"] }); } /** One installation admitting `` and backing it with one provider. */ function backed(answer: Implementation, options: ProviderOptions = {}): ExecutionInstallation { return { - evaluation: admits(), + install: admits, componentAnswers: [answerProvider("Open", answer.definition, options)], }; } @@ -2230,13 +2276,21 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off }); return yield* run( OPEN, - [{ ...backed(A), evaluation: admits(), install: () => useRecordedFiles(files) }], + [ + { + ...backed(A), + *install(): Operation { + yield* admits(); + yield* useRecordedFiles(files); + }, + }, + ], stream, ); }), ); - expect(failed).toContain("no provider published"); + expect(failed).toContain("nor one a provider published"); // Ahead of everything: no admission, neither body, no fragment effect. expect(admissions(yield* stream.readAll())).toHaveLength(0); expect(A.invoked).toEqual([]); @@ -2259,7 +2313,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off // honest. const output = yield* run(OPEN, [ { - evaluation: admits(), + install: admits, componentAnswers: [ answerProvider("Open", B.definition, { delegatesFirst: true, @@ -2299,7 +2353,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off // and the substitution refuses it. const output = yield* run(`\\n'} as="answer" />\n`, [ { - evaluation: admits(), + install: admits, componentAnswers: [ answerProvider("Open", A.definition, { alternating: { @@ -2333,7 +2387,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off OPEN, [ { - evaluation: admits(), + install: admits, componentAnswers: [answerProvider("Open", A.definition, { keepsAnswering: true })], }, ], @@ -2361,7 +2415,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off OPEN, [ { - evaluation: admits(), + install: admits, componentAnswers: [answerProvider("Open", A.definition, { publishesLater: true })], }, ], @@ -2389,8 +2443,10 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off OPEN, [ { - evaluation: admits(), - install: () => useRecordedFiles(files), + *install(): Operation { + yield* admits(); + yield* useRecordedFiles(files); + }, componentAnswers: [answerProvider("Open", A.definition, options)], }, ], @@ -2398,7 +2454,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off ), ); - expect([what, failed.includes("no provider published")]).toEqual([what, true]); + expect([what, failed.includes("nor one a provider published")]).toEqual([what, true]); // Before the root import, so there is no admission and no body — and the // document's own effects never started either. expect([what, admissions(yield* stream.readAll()).length]).toEqual([what, 0]); @@ -2422,8 +2478,10 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off OPEN, [ { - evaluation: admits(), - install: () => useRecordedFiles(files), + *install(): Operation { + yield* admits(); + yield* useRecordedFiles(files); + }, componentAnswers: [answerProvider("Open", stating)], }, ], @@ -2451,8 +2509,10 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off OPEN, [ { - evaluation: admits(), - install: () => useRecordedFiles(files), + *install(): Operation { + yield* admits(); + yield* useRecordedFiles(files); + }, componentAnswers: [answerProvider("Open", A.definition)], }, ], @@ -2491,7 +2551,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off OPEN, [ { - evaluation: admits(), + install: admits, componentAnswers: [ answerProvider("Open", B.definition, { delegatesFirst: true, delegated }), answerProvider("Open", resumedA.definition), @@ -2539,7 +2599,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off `\\n'} as="two" />\n\n\n`, [ { - evaluation: admits(), + install: admits, componentAnswers: [answerProvider("Open", A.definition, { lookups, asked })], }, ], @@ -2569,8 +2629,10 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off `\\n'} as="answer" />\n\n\n`, [ { - evaluation: { read: [fileReadEntry()] }, - install: () => useRecordedFiles(files), + *install(): Operation { + yield* useSelection({ File: ["self-closing"] }); + yield* useRecordedFiles(files); + }, componentAnswers: [ answerProvider("Open", implementation("Open", "A ran").definition, { lookups }), ], @@ -2606,19 +2668,20 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => /** A profile admitting the write table Elicit belongs to. */ function writing(files: RecordedFiles): ExecutionInstallation { return { - evaluation: { - read: [fileReadEntry()], - write: [fileWriteEntry(), elicitWriteEntry()], + *install(): Operation { + yield* useSelection({ File: ["self-closing", "paired"], Elicit: ["paired"] }); + yield* useRecordedFiles(files); }, - install: () => useRecordedFiles(files), }; } /** The same tables, with the question left out of the one that holds it. */ function withoutElicit(files: RecordedFiles): ExecutionInstallation { return { - evaluation: { read: [fileReadEntry()], write: [fileWriteEntry()] }, - install: () => useRecordedFiles(files), + *install(): Operation { + yield* useSelection({ File: ["self-closing", "paired"] }); + yield* useRecordedFiles(files); + }, }; } @@ -2699,17 +2762,16 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => expect(kinds).toContain("elicit"); }); - it("EL7: an admitted question nothing writes resolves nothing, and breaks nothing", function* () { + it("EL7: a selected question is resolved at capture even when nothing writes it", function* () { const files = recordedFiles(); const requests: ElicitationRequest[] = []; const asksFor: string[] = []; - // The entry is admitted and the fragment never writes the element. Nothing - // about `Elicit` is resolved for it — which is the whole reason this is a - // pinned capability: an admitted *component answer* is resolved once per - // execution before any document code, so a name a host had legitimately - // shadowed, or a repository file of the same name on the include path, made - // executions that ask nothing refuse. + // Every selected name is resolved once, before public execution policy, + // whether or not the fragment goes on to write it. That is the whole of the + // substitution this closes: a name resolved lazily would be resolved + // through whatever middleware the running document had arranged by then. + // So `Elicit` is asked for here — once — and nobody is asked a question. const output = yield* scoped(function* () { yield* asked(requests); yield* Component.around({ @@ -2722,7 +2784,9 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => return yield* run(DOCUMENT, [writing(files)]); }); - expect(asksFor).not.toContain("Elicit"); + // Resolved before the root import, which is what "at capture" means here. + expect(asksFor.slice(0, asksFor.indexOf("__root__"))).toContain("Elicit"); + expect(asksFor.filter((name) => name === "Elicit")).toHaveLength(1); expect(requests).toEqual([]); expect(files.entries.get("out.md")).toContain("just a write"); expect(String(output)).not.toContain("did not admit"); @@ -2810,48 +2874,46 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => expect(files.performed).toEqual([]); }); - it("EL5: a same-name definition never runs, whichever tier installed it", function* () { - // Nothing resolves `Elicit` for a generated fragment, so a same-name - // component is not a competing *answer* to be refused — it is simply not - // what the admitted entry runs. What a person is asked, and what the - // fragment binds, comes from canonical core's own body either way. - // - // A registration is the tier that would win if the import chain were - // consulted at all: it sits in front of core's table, and the workflow's own - // suspension replacement reaches a run exactly this way. + it("EL5: a selected question resolves the actual host answer, and refuses an unoffered one", function* () { + // `Elicit` is a replaceable name, and selecting it resolves what this host + // actually answers for it. A registration that shadows core's own is + // therefore what a selected `` would run — and a registration whose + // declaration offers no generated use of the paired form has no identity + // for a continuation to be compared against, so setup refuses rather than + // silently falling back to core's default. const impostorAnswer = { decision: "Impostor" }; const registered = recordedFiles(); const registeredAsks: ElicitationRequest[] = []; let impostorRan = false; - yield* scoped(function* () { - yield* asked(registeredAsks); - yield* registerComponents([ - { - name: "Elicit", - origin: "test://impostor", - props: { type: "object", properties: {}, additionalProperties: true }, - // deno-lint-ignore require-yield - *fn(): Operation { - impostorRan = true; - return impostorAnswer; + const refusedRegistration = yield* refusal( + scoped(function* () { + yield* asked(registeredAsks); + yield* registerComponents([ + { + name: "Elicit", + origin: "test://impostor", + props: { type: "object", properties: {}, additionalProperties: true }, + // deno-lint-ignore require-yield + *fn(): Operation { + impostorRan = true; + return impostorAnswer; + }, }, - }, - ]); - yield* producing(ASKING); - return yield* run(DOCUMENT, [writing(registered)]); - }); - // The real provider was asked, the impostor was not run, and what reached - // the write is the provider's answer rather than the impostor's. - expect(registeredAsks).toHaveLength(1); + ]); + yield* producing(ASKING); + return yield* run(DOCUMENT, [writing(registered)]); + }), + ); + expect(refusedRegistration).toContain("offers no generated use of it"); + // Nobody was asked, nothing was written, and the shadowing body never ran: + // the refusal lands at setup, before the producer and before any effect. expect(impostorRan).toBe(false); - expect(registered.entries.get("out.md")).toContain("Approve"); - expect(registered.entries.get("out.md")).not.toContain("Impostor"); - - // And middleware that answers the name with a body of its own does not get - // to be what the fragment runs. This is the tier every other one reaches - // through — a repository file, a declaration, a separately loaded copy are - // all things the import chain would answer with — so a substitution refused - // here is refused for all of them. + expect(registeredAsks).toEqual([]); + expect(registered.performed).toEqual([]); + + // And middleware that answers the name with a body of its own is refused on + // the other terms: an implementation nobody published is not one this + // execution may retain, whatever metadata it carries. const wrapped = recordedFiles(); const wrappedAsks: ElicitationRequest[] = []; let middlewareRan = false; @@ -2867,6 +2929,12 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => kind: "function", name: "Elicit", props: { type: "object", properties: {}, additionalProperties: true }, + generated: { + paired: { + identity: "https://executable.md/components/elicit/ask", + effect: "write", + }, + }, // deno-lint-ignore require-yield *fn(): Operation { middlewareRan = true; @@ -2879,10 +2947,10 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => return yield* run(DOCUMENT, [writing(wrapped)]); }), ); - // Only canonical execution answers a generated import. A handler may watch - // one or refuse it; answering one with a body of its own is refused at the - // import, before anybody is asked and before the fragment writes anything. - expect(refused).toContain("canonical execution did not produce"); + // Copying core's own URLs publishes nothing. A selected name runs the + // answer canonical resolution produced or one a provider published, and a + // replacement is neither — so it is refused before anybody is asked. + expect(refused).toContain("nor one a provider published"); expect(middlewareRan).toBe(false); expect(wrappedAsks).toEqual([]); expect(wrapped.performed).toEqual([]); @@ -2909,11 +2977,10 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => // one restated. const second = recordedFiles(); const moved: ExecutionInstallation = { - evaluation: { - read: [fileReadEntry()], - write: [fileWriteEntry()], + *install(): Operation { + yield* useSelection({ File: ["self-closing", "paired"] }); + yield* useRecordedFiles(second); }, - install: () => useRecordedFiles(second), }; const requests: ElicitationRequest[] = []; const failed = yield* refusal( diff --git a/packages/core/tests/evaluate-loaded-copy.test.ts b/packages/core/tests/evaluate-loaded-copy.test.ts index 32b90f293..cfd5eb039 100644 --- a/packages/core/tests/evaluate-loaded-copy.test.ts +++ b/packages/core/tests/evaluate-loaded-copy.test.ts @@ -30,8 +30,8 @@ import { API, fileWriteSuccess } from "@executablemd/runtime"; import { collect } from "../src/collect.ts"; import { Component } from "../src/component-api.ts"; import { executeInstalled } from "../host.ts"; -import { fileDeleteEntry, fileReadEntry, fileWriteEntry } from "../host.ts"; import type { ExecutionInstallation } from "../host.ts"; +import { useSelection } from "./support/generated-selection.ts"; import { registerComponents } from "../src/components/registration.ts"; import { retainedSource } from "../src/root-source.ts"; import { recordedFiles, useRecordedFiles } from "./support/fragment-files.ts"; @@ -40,19 +40,26 @@ import type { RecordedFiles } from "./support/fragment-files.ts"; const ROOT_PATH = "evaluate.md"; const NOTE = "the retained note\n"; -/** A read-only profile: no write table at all. */ +/** A read-only selection: core's `` and no writing spelling at all. */ function reading(files: RecordedFiles): ExecutionInstallation { - return { evaluation: { read: [fileReadEntry()] }, install: () => useRecordedFiles(files) }; + return { + *install(): Operation { + yield* useSelection({ File: ["self-closing"] }); + yield* useRecordedFiles(files); + }, + }; } -/** A profile offering both classes. */ +/** A selection whose spellings reach both classes. */ function both(files: RecordedFiles): ExecutionInstallation { return { - evaluation: { - read: [fileReadEntry()], - write: [fileWriteEntry(), fileDeleteEntry()], + *install(): Operation { + yield* useSelection({ + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + }); + yield* useRecordedFiles(files); }, - install: () => useRecordedFiles(files), }; } diff --git a/packages/core/tests/evaluate-provider-lifetime.test.ts b/packages/core/tests/evaluate-provider-lifetime.test.ts index a683014fb..471dcb20a 100644 --- a/packages/core/tests/evaluate-provider-lifetime.test.ts +++ b/packages/core/tests/evaluate-provider-lifetime.test.ts @@ -30,12 +30,15 @@ import type { ComponentAnswerRegistrar, ComponentAnswerRequest, ExecutionInstallation, - FragmentEvaluationInput, } from "../host.ts"; import { registerComponents } from "../src/components/registration.ts"; -import { prepareEvaluationProfile } from "../src/evaluation-profile.ts"; -import type { ComponentAnswerEntry, ResolvedAnswer } from "../src/evaluation-profile.ts"; -import { REVOKED_CAPABILITY } from "../src/fragment-capabilities.ts"; +import { + captureEvaluationProfile, + evaluationLifetime, + REVOKED, +} from "../src/evaluation-profile.ts"; +import type { ResolvedAnswer } from "../src/evaluation-profile.ts"; +import { useSelection } from "./support/generated-selection.ts"; import { retainedSource } from "../src/root-source.ts"; import { answerProvider, implementation, offering } from "./support/answer-provider.ts"; import type { Implementation } from "./support/answer-provider.ts"; @@ -44,14 +47,9 @@ import type { ComponentInvocation } from "../src/invocation-identity.ts"; const ROOT_PATH = "evaluate.md"; const OPEN = `\\n'} as="answer" />\n\n\n`; -/** One provider-backed entry, in the one spelling this suite's providers offer. */ -function answerEntry(name: string): ComponentAnswerEntry { - return { kind: "component-answer", name, forms: ["self-closing"] }; -} - -/** The entry a host states for a provider-backed name. */ -function admits(): FragmentEvaluationInput { - return { read: [answerEntry("Open")] }; +/** The one name, in the one spelling, this suite's providers answer for. */ +function admits(): Operation { + return useSelection({ Open: ["self-closing"] }); } /** @@ -137,7 +135,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur OPEN, [ { - evaluation: admits(), + install: admits, componentAnswers: [ { origin: "test://provider", @@ -192,7 +190,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur // The provider keeps the request its handler received. const output = yield* run(OPEN, [ { - evaluation: admits(), + install: admits, componentAnswers: [answerProvider("Open", A.definition, { retain: retained })], }, ]); @@ -213,7 +211,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur const failed = yield* refusal( run(`\\n'} />\n`, [ { - evaluation: admits(), + install: admits, componentAnswers: [answerProvider("Open", A.definition, { retain: retained })], }, ]), @@ -267,7 +265,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur `\n\n\\n'} as="answer" />\n\n\n`, [ { - evaluation: admits(), + install: admits, componentAnswers: [ { origin: "test://losing", @@ -313,7 +311,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur // over. const output = yield* run(OPEN, [ { - evaluation: admits(), + install: admits, componentAnswers: [ { // The origin the profile admits: this is the provider whose answer @@ -384,8 +382,8 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur `\\n\\n'} as="answer" />\n\n\n`, [ { - evaluation: { - read: [answerEntry("Open"), answerEntry("Other")], + *install(): Operation { + yield* useSelection({ Open: ["self-closing"], Other: ["self-closing"] }); }, componentAnswers: [ answerProvider("Open", A.definition, { retain: stale }), @@ -438,8 +436,8 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur `\\n\\n'} as="answer" />\n\n\n`, [ { - evaluation: { - read: [answerEntry("Open"), answerEntry("Other")], + *install(): Operation { + yield* useSelection({ Open: ["self-closing"], Other: ["self-closing"] }); }, componentAnswers: [ { @@ -501,13 +499,13 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur yield* scoped(function* () { const running = yield* spawn(() => - run(OPEN, [{ evaluation: admits(), componentAnswers: [answerProvider("Open", held)] }]), + run(OPEN, [{ install: admits, componentAnswers: [answerProvider("Open", held)] }]), ); yield* entered.operation; // Started and finished while the first is suspended inside its fragment. const second = yield* run(OPEN, [ - { evaluation: admits(), componentAnswers: [answerProvider("Open", B.definition)] }, + { install: admits, componentAnswers: [answerProvider("Open", B.definition)] }, ]); expect(String(second)).toContain("B ran"); // The overlapping run did not reach into the first: its answer ran once, @@ -527,17 +525,19 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur it("FE15: a captured provider body cannot run after the execution ends", function* () { const A = implementation("Open", "A ran"); - // The internal seam: this is the two-stage capture canonical execution - // performs, driven directly, because there is no public way to reach a - // sealed profile — which is itself the point. A row that had to add a - // getter to observe this would be proving something about the getter. - const prepared = yield* prepareEvaluationProfile(admits()); - expect(prepared.answered).toEqual(["Open"]); - + // The internal seam: this is the capture canonical execution performs, + // driven directly, because there is no public way to reach a captured + // profile — which is itself the point. A row that had to add a getter to + // observe this would be proving something about the getter. + const lifetime = evaluationLifetime(); const answers = new Map([ ["Open", { definition: A.definition, origin: "test://provider" }], ]); - const profile = yield* prepared.seal(answers); + const profile = captureEvaluationProfile( + new Map([["Open", ["self-closing"] as const]]), + answers, + lifetime, + ); const sealed = profile.read[0]?.definition.fn; if (typeof sealed !== "function") { throw new Error("the profile sealed no invocable entry"); @@ -558,7 +558,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur } catch (error) { refused = error; } - expect(String(refused)).toContain(REVOKED_CAPABILITY); + expect(String(refused)).toContain(REVOKED); // The body itself never entered a second time. expect(A.invoked).toEqual(["A ran"]); }); @@ -566,15 +566,15 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur it("FE15: a later execution without the provider sees nothing the first left", function* () { const A = implementation("Open", "A ran"); const first = yield* run(OPEN, [ - { evaluation: admits(), componentAnswers: [answerProvider("Open", A.definition)] }, + { install: admits, componentAnswers: [answerProvider("Open", A.definition)] }, ]); expect(String(first)).toContain("A ran"); - // The same document and the same profile, with no provider installed. The + // The same document and the same selection, with no provider installed. The // first run's answer is not somewhere the second can find it: a publication leaves // no registry, no context and no module-level table behind it, so the name // resolves to nothing at all rather than to what the first run admitted. - const failed = yield* refusal(run(OPEN, [{ evaluation: admits() }])); + const failed = yield* refusal(run(OPEN, [{ install: admits }])); expect(failed).toContain("Cannot resolve component: Open"); expect(A.invoked).toEqual(["A ran"]); }); @@ -604,11 +604,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur yield* scoped(function* () { const running = yield* spawn(() => - run( - OPEN, - [{ evaluation: admits(), componentAnswers: [answerProvider("Open", slow)] }], - stream, - ), + run(OPEN, [{ install: admits, componentAnswers: [answerProvider("Open", slow)] }], stream), ); yield* reached.operation; yield* running.halt(); diff --git a/packages/core/tests/evaluation-profile.test.ts b/packages/core/tests/evaluation-profile.test.ts index 73c35e3f3..853a08ba2 100644 --- a/packages/core/tests/evaluation-profile.test.ts +++ b/packages/core/tests/evaluation-profile.test.ts @@ -1,15 +1,17 @@ /** - * Tier EP — what canonical execution captures from a host's evaluation profile. + * Tier EP — what canonical execution captures from a host's generated + * selection. * * `` is public, so any author may write it. What keeps that from - * being a capability is that the ceiling it narrows from was stated by a - * trusted host before a document existed — and that the execution stopped - * reading the host's objects the moment it captured them. + * being a capability is that what it narrows from was offered by a trusted host + * before a document existed — and that the execution stopped reading the host's + * objects the moment it captured them. * - * Every row here is about that second half. A host that edits its own tables, - * schemas, headers or roots after installation is editing objects nothing is - * looking at, and a profile that kept a reference instead of a copy would let - * it move the ceiling from inside its own `install()`. + * The host's half is small on purpose: component names and authored forms. The + * contract, the identity and the effect of every selected spelling come from + * the declaration that owns the implementation, so the rows here are about what + * capture *derives* and what it refuses to derive, rather than about a table a + * host wrote out. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -17,33 +19,30 @@ import { expect } from "@executablemd/test-support/expect"; import type { Operation } from "effection"; import { scoped, useScope } from "effection"; import { installIdentities, isFormDispatcher } from "../src/invocation-identity.ts"; -import type { ComponentRouting, ProtectedSite } from "../src/invocation-identity.ts"; +import type { + ComponentRouting, + InvocationForm, + ProtectedSite, +} from "../src/invocation-identity.ts"; import { - CANONICAL_PROFILE_ANSWERS, - elicitWriteEntry, - fileReadEntry, - globReadEntry, - prepareEvaluationProfile, - syntaxReadEntry, + captureEvaluationProfile, + evaluationLifetime, + REVOKED, } from "../src/evaluation-profile.ts"; import { CORE_ORIGIN } from "../src/components/registry.ts"; import { SYNTAX_PROTECTED } from "../src/components/Syntax.ts"; import { props as globProps } from "../src/components/Glob.ts"; -import { props as elicitProps } from "../src/components/Elicit.ts"; -import { capabilityReturns } from "../src/fragment-capabilities.ts"; +import { coreComponentDefinition } from "../src/components/registry.ts"; import { fixedFilesystemDefinition } from "../src/components/fixed-filesystem.ts"; import type { - CapabilityEntry, EvaluationProfile, - ComponentAnswerEntry, - FragmentEvaluationInput, - FragmentForm, ResolvedAnswer, ResolvedAnswers, } from "../src/evaluation-profile.ts"; +import type { AdmittedSelection } from "../src/generated-selection.ts"; import type { GeneratedForms } from "../src/components/generated-metadata.ts"; -import type { Json } from "../src/types.ts"; +import type { FunctionComponentDefinition, Json } from "../src/types.ts"; describe("protected route projection", () => { function installation() { @@ -100,11 +99,11 @@ describe("protected route projection", () => { if (original === undefined) { throw new Error("missing protected definition"); } - const prepared = yield* prepareEvaluationProfile({ - read: [{ kind: "component-answer", name: "Admitted", forms: ["self-closing"] }], - }); - const captured = yield* prepared.seal( + const lifetime = evaluationLifetime(); + const captured = captureEvaluationProfile( + new Map([["Admitted", ["self-closing"]]]), new Map([["Admitted", { definition: original, origin: "test://protected" }]]), + lifetime, owner.componentRouting.project, ); const sealed = captured.read[0]?.definition.fn; @@ -137,7 +136,7 @@ describe("protected route projection", () => { child.close(); expect(yield* invoke(child, wrapper)).toBe("unrouted"); expect(yield* invoke(owner.componentRouting, sealed)).toBe("Admitted:occurrence"); - prepared.revoke(); + lifetime.revoke(); } finally { owner.identities.revoke(); other.identities.revoke(); @@ -194,420 +193,267 @@ describe("protected route projection", () => { }); }); -function entry(overrides: Partial = {}): CapabilityEntry { - const name = overrides.name ?? "File"; - return { - kind: "capability", - name, - forms: ["self-closing"], - props: { type: "object", properties: {}, additionalProperties: false }, - // The capability follows the name unless a row states otherwise: what these - // rows are about is how a ceiling is captured, not which body runs. - capability: name === "Fetch" ? "fetch" : "file:read", - ...overrides, - }; -} - /** - * Capture a profile, with the operations every file entry needs supplied. + * The answers canonical resolution would have produced for these names. * - * A host that admits `` states the operations it runs, and a profile - * that does not is refused — EP17 covers that on its own. Every other row is - * about what capture does with a profile a host *can* state, so they all supply - * them and none of them restates the fact. + * Capture never resolves anything itself: by the time it runs, every selected + * name has been resolved once through the ordinary import chain and core holds + * its own copy of what the chain answered with. These rows hand it those copies + * directly, which is the seam, and core's own definitions are what they hand + * over wherever the name is core's. */ -function* capture(overrides: Partial = {}): Operation { - // Preparation copies and binds; sealing settles the provider-backed names. - // A capability-only profile resolves none, which is why every row here seals - // against no answers at all. - const prepared = yield* prepareEvaluationProfile({ - read: [entry()], - ...overrides, - }); - return yield* prepared.seal(new Map()); +function coreAnswers(...names: readonly string[]): ResolvedAnswers { + const answers = new Map(); + for (const name of names) { + const definition = fixedFilesystemDefinition(name) ?? coreComponentDefinition(name); + if (definition === undefined) { + throw new Error(`core supplies no ${name} component`); + } + answers.set(name, { definition, origin: CORE_ORIGIN }); + } + return answers; +} + +/** One selection, in the canonical order the admission produces. */ +function selection(stated: Readonly>): AdmittedSelection { + return new Map(Object.entries(stated)); +} + +/** Capture one selection against the answers its names resolved to. */ +function capture( + stated: Readonly>, + answers: ResolvedAnswers = coreAnswers(...Object.keys(stated)), +): EvaluationProfile { + return captureEvaluationProfile(selection(stated), answers, evaluationLifetime()); } -/** What capturing this profile refused with, as a string. */ -function* refusal(overrides: Partial): Operation { +/** What capturing this selection refused with, as a string. */ +function refusal( + stated: Readonly>, + answers?: ResolvedAnswers, +): string { try { - yield* capture(overrides); + capture(stated, answers); } catch (error) { return error instanceof Error ? error.message : String(error); } - throw new Error("expected the profile to be refused"); -} - -// deno-lint-ignore require-yield -function* emptyBasis(): Operation<{ roots: readonly string[]; current: string }> { - return { roots: [], current: "" }; + throw new Error("expected the capture to be refused"); } describe("Tier EP — a captured profile stops reading the host's objects", () => { - it("EP1: a table the host mutates after capture does not change the profile", function* () { - const read: CapabilityEntry[] = [entry()]; - const captured = yield* capture({ read }); - - read.push(entry({ name: "Added" })); - read.length = 0; - - expect(captured.read).toHaveLength(1); - expect(captured.read[0]?.name).toBe("File"); + it("EP1: a selection the host mutates after capture does not change the profile", function* () { + const stated: Record = { File: ["self-closing"] }; + const admitted = new Map(Object.entries(stated)); + const captured = captureEvaluationProfile(admitted, coreAnswers("File"), evaluationLifetime()); + + // The selection the host stated, and the validated map core derived from + // it, are both edited after capture. Neither reaches the profile. + stated["File.Delete"] = ["self-closing"]; + admitted.set("Glob", ["self-closing"]); + + expect(captured.read.map((admittedEntry) => admittedEntry.name)).toEqual(["File"]); + expect(captured.write).toEqual([]); + yield* useScope(); }); - it("EP2: a schema the host edits after capture does not change what validates", function* () { - const props: Record = { - type: "object", - properties: { path: { type: "string" } }, - additionalProperties: false, - }; - const captured = yield* capture({ read: [entry({ props })] }); - - props.additionalProperties = true; - const properties = props.properties; - if (typeof properties === "object" && properties !== null && !Array.isArray(properties)) { - properties.widened = { type: "string" }; - } + it("EP2: a schema the owner's definition carries is detached before it validates", function* () { + const captured = capture({ Glob: ["self-closing"] }); + const props = captured.read[0]?.props; - expect(captured.read[0]?.props.additionalProperties).toBe(false); - const held = captured.read[0]?.props.properties; - const names = - typeof held === "object" && held !== null && !Array.isArray(held) ? Object.keys(held) : []; - expect(names).toEqual(["path"]); + // The contract is the owning declaration's, copied rather than referenced: + // what a fragment's props are validated against cannot be edited through + // the definition the chain answered with. + expect(props).toEqual(globProps); + expect(props).not.toBe(globProps); + expect(Object.isFrozen(props)).toBe(true); + yield* useScope(); }); - it("EP3: forms the host edits after capture do not widen what is admitted", function* () { - const forms: FragmentForm[] = ["self-closing"]; - const captured = yield* capture({ read: [entry({ forms })] }); - - // The host's own array, pushed to after installation. Capture read it once - // and kept its own ordered copy, so a spelling added afterwards is added to - // an object nothing is looking at. - forms.push("paired"); + it("EP3: the forms a selection offers are copied, in canonical order", function* () { + const forms: InvocationForm[] = ["paired", "self-closing"]; + const captured = capture({ File: forms }); + forms.length = 0; + // Canonical order whatever order the host wrote, so two hosts stating the + // same pair stated the same thing — and the arrays on the profile are its + // own, so emptying the host's changes nothing. expect(captured.read[0]?.forms).toEqual(["self-closing"]); + expect(captured.write[0]?.forms).toEqual(["paired"]); + yield* useScope(); }); - it("EP4: request headers and lists the host mutates do not widen the ceiling", function* () { - const headers: Record = { accept: "text/plain" }; - const requests: Record[] = [{ url: "https://api.example.test/one", headers }]; - const captured = yield* capture({ - read: [entry({ name: "Fetch", requests })], - fetchTimeout: 1000, - }); - - headers.authorization = "Bearer widened"; - requests.push({ url: "https://api.example.test/two" }); - - const ceiling = captured.read[0]?.requests ?? []; - expect(ceiling).toHaveLength(1); - expect(Object.keys(ceiling[0]?.headers ?? {})).toEqual(["accept"]); - expect(ceiling[0]?.url).toBe("https://api.example.test/one"); - }); - - it("EP5: the host's resolved timeout is the ceiling, read once", function* () { - const captured = yield* capture({ - read: [entry({ name: "Fetch", requests: [{ url: "https://api.example.test/one" }] })], - fetchTimeout: 2500, - }); - - // The host resolved it when it built the profile. Nothing here reads a - // context, so where preflight later happens cannot change the ceiling. - expect(captured.read[0]?.requests?.[0]?.timeout).toBe(2500); - }); - - it("EP6: a request stating its own timeout outranks the host default", function* () { - const captured = yield* capture({ - read: [ - entry({ - name: "Fetch", - // The prop grammar, which is a duration rather than milliseconds. - requests: [{ url: "https://api.example.test/one", timeout: "100ms" }], - }), - ], - fetchTimeout: 2500, - }); - - expect(captured.read[0]?.requests?.[0]?.timeout).toBe(100); - }); - - it("EP7: one ceiling stated twice is one ceiling", function* () { - const captured = yield* capture({ - read: [ - entry({ - name: "Fetch", - requests: [ - { url: "https://api.example.test/one" }, - { url: "https://api.example.test/one" }, - ], - }), - ], - fetchTimeout: 1000, - }); - - expect(captured.read[0]?.requests).toHaveLength(1); - }); - - it("EP8: ceilings are canonically ordered, so two statements of one set match", function* () { - const one = yield* capture({ - read: [ - entry({ - name: "Fetch", - requests: [{ url: "https://api.example.test/a" }, { url: "https://api.example.test/b" }], - }), - ], - fetchTimeout: 1000, - }); - const other = yield* capture({ - read: [ - entry({ - name: "Fetch", - requests: [{ url: "https://api.example.test/b" }, { url: "https://api.example.test/a" }], - }), - ], - fetchTimeout: 1000, - }); - - expect(one.read[0]?.requests?.map((request) => request.url)).toEqual( - other.read[0]?.requests?.map((request) => request.url), + it("EP4: a captured implementation refuses once the execution has ended", function* () { + const lifetime = evaluationLifetime(); + const captured = captureEvaluationProfile( + selection({ File: ["self-closing"] }), + coreAnswers("File"), + lifetime, ); - }); - - it("EP9: each entry keeps its own ceiling rather than a flattened one", function* () { - const captured = yield* capture({ - read: [ - entry({ name: "First", requests: [{ url: "https://api.example.test/first" }] }), - entry({ name: "Second", requests: [{ url: "https://api.example.test/second" }] }), - ], - fetchTimeout: 1000, - }); - - // Flattening them would let the first entry's limit admit the second's - // request: a ceiling belongs to the entry that states it. - expect(captured.read[0]?.requests?.map((request) => request.url)).toEqual([ - "https://api.example.test/first", - ]); - expect(captured.read[1]?.requests?.map((request) => request.url)).toEqual([ - "https://api.example.test/second", - ]); - }); - it("EP10: a Workspace answer the host mutates afterwards does not move the basis", function* () { - const roots = ["workspace://one"]; - const captured = yield* capture({ - read: [entry()], - workspace: { - // deno-lint-ignore require-yield - *snapshot() { - return { roots, current: "workspace://one" }; - }, - }, - }); - - const basis = yield* captured.workspace?.snapshot() ?? emptyBasis(); - roots.push("workspace://two"); - - expect(basis.roots).toEqual(["workspace://one"]); - }); + expect(captured.live()).toBe(true); + captured.revoke(); + expect(captured.live()).toBe(false); + expect(lifetime.live()).toBe(false); - it("EP11: replacing the host's snapshot method after capture reaches nothing", function* () { - const access = { - // deno-lint-ignore require-yield - *snapshot(): Operation<{ roots: readonly string[]; current: string }> { - return { roots: ["workspace://honest"], current: "workspace://honest" }; - }, - }; - const captured = yield* capture({ read: [entry()], workspace: access }); - - // deno-lint-ignore require-yield - access.snapshot = function* () { - return { roots: ["workspace://substituted"], current: "workspace://substituted" }; - }; - - const basis = yield* captured.workspace?.snapshot() ?? emptyBasis(); - expect(basis.roots).toEqual(["workspace://honest"]); + const held = captured.read[0]?.definition.fn; + if (typeof held !== "function") { + throw new Error("the capture sealed no invocable entry"); + } + let refused = ""; + try { + yield* held({}, { hasContent: () => false }); + } catch (error) { + refused = error instanceof Error ? error.message : String(error); + } + expect(refused).toContain(REVOKED); }); }); -describe("Tier EP — a profile a host cannot state", () => { - it("EP12: an entry admitted in the table its owner does not offer refuses", function* () { - // What a spelling does belongs to the component. `` offers its paired - // form as a write, so admitting that spelling in the read table would hand - // a read selection a mutation — and `` offers no paired form - // at all, so admitting one would admit a spelling with no identity for a - // continuation to be compared against. - const wrongTable = yield* refusal({ - read: [entry({ name: "File", capability: "file:write", forms: ["paired"] })], - }); - expect(wrongTable).toContain("its owner offers that form as a write"); - - const unoffered = yield* refusal({ - read: [entry({ name: "File.Delete", capability: "file:delete", forms: ["paired"] })], +describe("Tier EP — the table a spelling lands in is the owner's answer", () => { + it("EP5: one name's two spellings are derived into the two tables", function* () { + const captured = capture({ File: ["self-closing", "paired"] }); + + // The selection says `` in both spellings. Which of them observes and + // which mutates is on core's own declaration, so the host never divided + // them — and the implementation behind both is the same object. + expect(captured.read.map((entry) => entry.name)).toEqual(["File"]); + expect(captured.write.map((entry) => entry.name)).toEqual(["File"]); + expect(captured.read[0]?.definition).toBe(captured.write[0]?.definition); + expect(captured.read[0]?.props).toBe(captured.write[0]?.props); + expect(captured.read[0]?.definition.generated).toEqual({ + "self-closing": { identity: "https://executable.md/components/file/read", effect: "read" }, + paired: { identity: "https://executable.md/components/file/write", effect: "write" }, }); - expect(unoffered).toContain("offers no generated use of it"); }); - it("EP13: an entry admitted for no form refuses", function* () { - expect(yield* refusal({ read: [entry({ forms: [] })] })).toContain("no authored form"); - }); + it("EP6: a selection whose spellings all write derives no read table", function* () { + const captured = capture({ File: ["paired"], "File.Delete": ["self-closing"] }); - it("EP14: a request entry with no request it may perform refuses", function* () { - expect(yield* refusal({ read: [entry({ name: "Fetch", requests: [] })] })).toContain( - "no request it may perform", - ); + expect(captured.read).toEqual([]); + expect(captured.write.map((entry) => entry.name)).toEqual(["File", "File.Delete"]); }); - it("EP15: a profile stating no component at all refuses", function* () { - expect(yield* refusal({ read: [] })).toContain("no component at all"); - }); - - it("EP17: a file entry states no operations, and runs the component core owns", function* () { - // The inverse of what this row asserted while the profile carried a private - // transport, where a host admitting `` without stating operations - // had admitted something it could not perform. There is nothing left for a - // host to state: the entry runs canonical core's own component, which - // performs its work through whichever provider is installed where the - // fragment runs. So the admission is complete on its own — and an absent - // provider is fatal at the operation, exactly as it is for an authored - // element. - const captured = yield* capture({ read: [fileReadEntry()] }); - const admitted = captured.read[0]; - - expect(admitted?.kind).toBe("capability"); - expect(admitted?.capability).toBe("file:read"); - // The owning component's own contract, read off it rather than restated. - expect(admitted?.props).toEqual(fixedFilesystemDefinition("File")?.props); - // And the component's own dispatcher travels with it, so canonical - // selection enters the body the scan chose rather than a wrapper's. - expect(isFormDispatcher(admitted?.dispatch)).toBe(true); - }); - - it("EP21: core's own read entries state exactly what a host may admit them as", function* () { - const captured = yield* capture({ read: [fileReadEntry(), globReadEntry()] }); + it("EP7: the contract, the origin and the dispatcher come from the owner", function* () { + const captured = capture({ File: ["self-closing"], Glob: ["self-closing"] }); const [file, glob] = captured.read; - // The identity each spelling runs under is on the definition, where core's - // own declaration states it: the reading form observes and the writing form - // mutates, and one admitted entry carries both because one component does. - expect(file?.definition.generated).toEqual({ - "self-closing": { identity: "https://executable.md/components/file/read", effect: "read" }, - paired: { identity: "https://executable.md/components/file/write", effect: "write" }, - }); expect(file?.origin).toBe(CORE_ORIGIN); - expect(file?.forms).toEqual(["self-closing"]); - expect(file?.definition.returns).toBe(undefined); + expect(file?.props).toEqual(fixedFilesystemDefinition("File")?.props); + // The component's own dispatcher travels with it, so canonical selection + // enters the body the scan chose rather than the lifetime guard around it. + expect(isFormDispatcher(file?.dispatch)).toBe(true); // The search is the same component an author writes: the ordinary props, // the one spelling, and a declared return — which is what makes `as` - // mandatory before the host's provider is asked to traverse anything. - expect(glob?.definition.generated).toEqual({ - "self-closing": { identity: "https://executable.md/components/glob/read", effect: "read" }, - }); - expect(glob?.forms).toEqual(["self-closing"]); + // mandatory before the provider is asked to traverse anything. expect(glob?.props).toEqual(globProps); expect(glob?.definition.returns).toEqual({ type: "array", items: { type: "string" } }); - // One spelling, so one offered form: nothing admits a paired ``. expect(Object.keys(glob?.definition.generated ?? {})).toEqual(["self-closing"]); }); - it("EP22: canonical Syntax is admitted as an answer, and states no identity", function* () { - const stated = syntaxReadEntry(); - - expect(stated.kind).toBe("component-answer"); - expect(stated.name).toBe("Syntax"); - expect(stated.forms).toEqual(["self-closing"]); - // The arm carries no implementation and no identity at all: what is behind - // a protected name, and what admitting its one spelling grants, are - // canonical resolution's to answer rather than a host's to hand over. - expect(Object.keys(stated)).not.toContain("capability"); - expect(Object.keys(stated)).not.toContain("identity"); - // Core's own declaration is where that spelling's identity and effect are. + it("EP8: core's protected `` is one selected name like any other", function* () { + const captured = capture({ Syntax: ["self-closing"] }, syntaxAnswer()); + + // The selection carries the name and the spelling and nothing else. What is + // behind a protected name, and what the one spelling grants, are canonical + // resolution's to answer and the declaration's to state. + expect(captured.read.map((entry) => entry.name)).toEqual(["Syntax"]); expect(SYNTAX_PROTECTED.generated).toEqual({ "self-closing": { identity: "https://executable.md/components/syntax/read", effect: "read" }, }); + yield* useScope(); }); +}); - it("EP23: one name admitted for two different components refuses", function* () { - // `` holds two capabilities under one name legitimately, because both - // belong to the same component: its two spellings are two bodies of one - // ``. Two capabilities owned by *different* components would be one - // name with two answers, decided by how the element happened to be written - // — and the two would disagree about what `as` means, which is how this - // same mistake used to be caught. The owning component is what is compared - // now, because that is the earlier and more general fact: a name that runs - // two components is already wrong, whatever either of them binds. - expect( - yield* refusal({ - read: [entry({ name: "File", capability: "files:glob", forms: ["self-closing"] })], - write: [entry({ name: "File", capability: "file:write", forms: ["paired"] })], - }), - ).toContain("two different components"); +describe("Tier EP — a selection a host cannot capture", () => { + it("EP9: a spelling its owner offers no generated use of refuses", function* () { + // `` offers the self-closing form alone, so selecting the + // paired one would admit a spelling with no identity for a continuation to + // be compared against. + expect(refusal({ "File.Delete": ["paired"] })).toContain("offers no generated use of it"); + yield* useScope(); }); - it("EP16: forms are canonically ordered, so two statements of one pair match", function* () { - // A provider whose component observes in both spellings, which is what a - // form-insensitive read is: both are offered as reads, so both may be - // admitted in the read table and the order the host wrote them in is not - // part of what it stated. - const both: GeneratedForms = { - "self-closing": { identity: "https://executable.md/components/pair/read", effect: "read" }, - paired: { identity: "https://executable.md/components/pair/also", effect: "read" }, - }; + it("EP10: a selected name this execution resolved nothing for refuses", function* () { + expect(refusal({ File: ["self-closing"] }, new Map())).toContain("resolved no implementation"); + yield* useScope(); + }); + + it("EP11: an answer with nothing to invoke refuses", function* () { const answers: ResolvedAnswers = new Map([ [ - "Pair", + "Open", + { + origin: "test://provider", + definition: { + kind: "function", + name: "Open", + props: { type: "object", properties: {}, additionalProperties: false }, + generated: { + "self-closing": { + identity: "https://executable.md/components/open/read", + effect: "read" as const, + }, + }, + fn: undefined, + } as unknown as FunctionComponentDefinition, + }, + ], + ]); + expect(refusal({ Open: ["self-closing"] }, answers)).toContain("no implementation to invoke"); + yield* useScope(); + }); + + it("EP12: selecting a trusted composition name refuses", function* () { + // Closed-world conflict detection, at capture: a host never gets to give an + // effect to core's own pure component. + expect(refusal({ Json: ["self-closing"] })).toContain("trusted composition"); + yield* useScope(); + }); + + it("EP13: a declaration whose generated metadata is unusable refuses", function* () { + const answers: ResolvedAnswers = new Map([ + [ + "Open", { origin: "test://provider", definition: { kind: "function" as const, - name: "Pair", + name: "Open", props: { type: "object", properties: {}, additionalProperties: false }, - generated: both, + // Not an absolute URL in the accepted namespace, so the owner has + // stated no identity a continuation could be compared against. + generated: { "self-closing": { identity: "components/open/read", effect: "read" } }, // deno-lint-ignore require-yield *fn(): Operation { - return "paired"; + return "opened"; }, }, }, ], ]); - const admitting = function* (forms: readonly FragmentForm[]): Operation { - const prepared = yield* prepareEvaluationProfile({ - read: [{ kind: "component-answer", name: "Pair", forms }], - }); - return yield* prepared.seal(answers); - }; - - const one = yield* admitting(["paired", "self-closing"]); - const other = yield* admitting(["self-closing", "paired"]); - - expect(one.read[0]?.forms).toEqual(other.read[0]?.forms); - expect(one.read[0]?.forms).toEqual(["self-closing", "paired"]); + expect(refusal({ Open: ["self-closing"] }, answers)).toContain("absolute URL"); + yield* useScope(); }); }); /** * Tier EP — one name, one implementation. * - * A provider-backed name is resolved once through the ordinary import chain and - * sealed. So what a profile may say about a name is settled here rather than at - * the lookup: two entries under one name are the two spellings of one - * component, each reading its own identity and effect off the one definition - * the chain answered with. + * A selected name is resolved once through the ordinary import chain and + * captured. So what a selection may say about a name is settled here rather + * than at the lookup: its two spellings are two grants of one component, each + * reading its own identity and effect off the one definition the chain answered + * with. */ describe("Tier EP — a provider-backed name resolves once to one definition", () => { - const OPEN: ComponentAnswerEntry = { - kind: "component-answer", - name: "Open", - forms: ["self-closing"], + /** What that provider's declaration offers each of its two spellings as. */ + const BOTH_FORMS: GeneratedForms = { + "self-closing": { identity: "https://executable.md/components/open/read", effect: "read" }, + paired: { identity: "https://executable.md/components/open/write", effect: "write" }, }; - /** - * One implementation, as canonical execution hands sealing its answer. - * - * It offers both spellings, each under its own identity and effect, because - * that is what a provider whose component reads one way and writes the other - * declares — and it is one definition doing it. - */ + /** One implementation, as canonical execution hands capture its answer. */ function answered(generated: GeneratedForms = BOTH_FORMS): ResolvedAnswers { return new Map([ [ @@ -629,56 +475,11 @@ describe("Tier EP — a provider-backed name resolves once to one definition", ( ]); } - /** What that provider's declaration offers each of its two spellings as. */ - const BOTH_FORMS: GeneratedForms = { - "self-closing": { identity: "https://executable.md/components/open/read", effect: "read" }, - paired: { identity: "https://executable.md/components/open/write", effect: "write" }, - }; - - /** Prepare a profile holding these entries, without sealing it. */ - function prepare(input: Partial) { - return prepareEvaluationProfile({ read: [entry()], ...input }); - } - - /** What sealing this prepared profile refused with. */ - function* sealing( - prepared: { seal: (answers: ResolvedAnswers) => Operation }, - answers: ResolvedAnswers, - ): Operation { - try { - yield* prepared.seal(answers); - } catch (error) { - return error instanceof Error ? error.message : String(error); - } - throw new Error("expected sealing to be refused"); - } - - /** What preparing this profile refused with. */ - function* refused(input: Partial): Operation { - try { - yield* prepare(input); - } catch (error) { - return error instanceof Error ? error.message : String(error); - } - throw new Error("expected the profile to be refused"); - } + it("EP18: one name across both spellings is one implementation in two tables", function* () { + const captured = capture({ Open: ["self-closing", "paired"] }, answered()); + const observing = captured.read[0]; + const mutating = captured.write[0]; - it("EP18: one name across disjoint forms and tables is one lookup and one implementation", function* () { - // The self-closing spelling admitted to observe and the paired one to - // mutate: two entries, two forms, two tables, one component. The host says - // the name, and the definition says what each spelling runs as. - const prepared = yield* prepare({ - read: [OPEN], - write: [{ ...OPEN, forms: ["paired"] }], - }); - - // Asked for once, however many entries hold it: a second lookup would be a - // second chance for the chain to answer differently. - expect(prepared.answered).toEqual(["Open"]); - - const profile = yield* prepared.seal(answered()); - const observing = profile.read[0]; - const mutating = profile.write[0]; expect(observing?.forms).toEqual(["self-closing"]); expect(mutating?.forms).toEqual(["paired"]); // Two URLs and two effects, read off the one definition: the reading @@ -686,79 +487,52 @@ describe("Tier EP — a provider-backed name resolves once to one definition", ( // behind them is the same object. expect(observing?.definition.generated).toEqual(BOTH_FORMS); expect(observing?.origin).toBe("test://provider"); - // One sealed implementation, shared. Two guards over one answer would be + // One captured implementation, shared. Two guards over one answer would be // two lifetimes for one implementation, and which of them a fragment // reached would depend on which table admitted it. expect(observing?.definition).toBe(mutating?.definition); expect(observing?.props).toBe(mutating?.props); + yield* useScope(); }); - it("EP19: an answer that does not offer the admitted spelling refuses at sealing", function* () { + it("EP19: an answer that does not offer a selected spelling refuses at capture", function* () { // The implementation is resolved once and then held to what its own - // declaration offers. A host cannot describe a spelling the provider never - // offered, and cannot move one into the other effect table: either would be - // a grant the implementation does not have, and both refuse before an - // execution holds a profile. - const prepared = yield* prepare({ read: [OPEN], write: [{ ...OPEN, forms: ["paired"] }] }); + // declaration offers. A selection cannot name a spelling the provider never + // offered: that would be a grant the implementation does not have, and it + // refuses before an execution holds a profile. const readOnly: GeneratedForms = { "self-closing": { identity: "https://executable.md/components/open/read", effect: "read" }, }; - expect(yield* sealing(prepared, answered(readOnly))).toContain("offers no generated use of it"); + expect(refusal({ Open: ["self-closing", "paired"] }, answered(readOnly))).toContain( + "offers no generated use of it", + ); + yield* useScope(); + }); + it("EP20: both spellings offered as reads land in the read table together", function* () { + // A provider whose component observes in both spellings, which is what a + // form-insensitive read is. The effect is the owner's answer, so both + // spellings are reads and the selection decided nothing about it. const bothRead: GeneratedForms = { "self-closing": { identity: "https://executable.md/components/open/read", effect: "read" }, paired: { identity: "https://executable.md/components/open/also", effect: "read" }, }; - expect(yield* sealing(prepared, answered(bothRead))).toContain( - "its owner offers that form as a read", - ); - }); + const captured = capture({ Open: ["paired", "self-closing"] }, answered(bothRead)); - it("EP20: one name held as both a capability and an answer refuses", function* () { - // The same ambiguity with the sharper edge: canonical core would supply one - // body and the import chain the other, which are different grants under one - // spelling. - const failed = yield* refused({ - read: [entry({ name: "Open", forms: ["self-closing"] }), { ...OPEN, forms: ["paired"] }], - }); - - expect(failed).toContain("different grants"); - }); -}); - -describe("Tier EP — the entry that admits canonical ``", () => { - it("EL1: the factory pins core's own body, paired only", function* () { - const entry = elicitWriteEntry(); - - // A capability rather than a component answer: what core owns here is the - // behavior, so the body is pinned the way ``'s is. Resolving the name - // instead would hand a generated fragment whatever legitimately shadows - // `Elicit` — a repository file, a registration, the workflow's own - // suspension replacement — and would resolve it for every execution the - // profile belongs to, including ones that ask nothing. - expect(entry.kind).toBe("capability"); - expect(entry.capability).toBe("elicit:ask"); - expect(entry.name).toBe("Elicit"); - expect(Object.keys(entry)).not.toContain("identity"); - // Paired only. The question is the content, so a self-closing spelling - // would be a request with nothing in it. - expect(entry.forms).toEqual(["paired"]); - // It declares ``'s own contract, read from the component rather than - // restated, and binds a value — so a generated question written without - // `as` is refused for the ordinary reason, before anybody is asked. - expect(entry.props).toEqual(elicitProps); - expect(capabilityReturns("elicit:ask")).toBeDefined(); - - // And it is not a name anything resolves. `Elicit` left the set canonical - // execution resolves eagerly, which is what stops an execution that never - // asks anything from depending on what `Elicit` resolves to. - expect(CANONICAL_PROFILE_ANSWERS.has("Elicit")).toBe(false); - - // Two factories state the same entry and share nothing: a host that edited - // what it was given would be editing its own copy. - const second = elicitWriteEntry(); - expect(second).toEqual(entry); - expect(second).not.toBe(entry); + expect(captured.write).toEqual([]); + expect(captured.read).toHaveLength(1); + expect(captured.read[0]?.forms).toEqual(["self-closing", "paired"]); yield* useScope(); }); }); + +/** Canonical ``, as canonical resolution answers for it. */ +function syntaxAnswer(): ResolvedAnswers { + const identity = installIdentities([], [], [SYNTAX_PROTECTED]); + identity.activate(); + const definition = identity.protected.get("Syntax"); + if (definition === undefined) { + throw new Error("core supplies no Syntax component"); + } + return new Map([["Syntax", { definition, origin: CORE_ORIGIN }]]); +} diff --git a/packages/core/tests/execution-init.test.ts b/packages/core/tests/execution-init.test.ts index ee88ab28e..20d4df1fb 100644 --- a/packages/core/tests/execution-init.test.ts +++ b/packages/core/tests/execution-init.test.ts @@ -7,8 +7,9 @@ import { Execution } from "../api.ts"; import { parseFilesFatal } from "@executablemd/runtime"; import { fatalCause } from "../src/errors.ts"; import { collect, execute, Execution as RootExecution, inlineSource } from "../mod.ts"; -import { executeInstalled, fileReadEntry, Markdown, sourceDigest } from "../host.ts"; +import { executeInstalled, Markdown, sourceDigest } from "../host.ts"; import type { JournalAdmission } from "../host.ts"; +import { useSelection } from "./support/generated-selection.ts"; import { Documentation } from "../src/documentation-api.ts"; import { recordedFiles, useRecordedFiles } from "./support/fragment-files.ts"; @@ -99,7 +100,17 @@ describe("Tier EI — execution-owned initialization", () => { expect(order).toEqual(["init", "install", "documentation", "execute", "document"]); }); - it("EI3: init cannot rewrite captured admissions, declarations or generated profile tables", function* () { + /** + * EI3 — what init runs *after*, and what it runs *before*. + * + * Admissions and declarations are read once and copied before any + * installation runs, so init editing the host's arrays changes nothing. The + * generated selection is deliberately the other way round: init is where a + * host establishes it, and core reads the final contextual answer afterwards + * — before public execution policy, which is the boundary the selection is + * protected at. That half is `generated-selection` U5-2's. + */ + it("EI3: init cannot rewrite captured admissions or declarations", function* () { const admitted: string[] = []; const admissions: JournalAdmission[] = [ function* () { @@ -110,13 +121,14 @@ describe("Tier EI — execution-owned initialization", () => { const declarations = [ Markdown({ name: "Kept", origin: "test://init/kept", source, digest: sourceDigest(source) }), ]; - const read = [fileReadEntry()]; const files = recordedFiles({ "note.md": "CAPTURED PROFILE" }); yield* Execution.around({ *init(_args, next) { admissions.length = 0; declarations.length = 0; - read.length = 0; + // The selection, established here: this is init's own job rather than + // something it is editing behind core's back. + yield* useSelection({ File: ["self-closing"] }); yield* useRecordedFiles(files); yield* next(); }, @@ -124,7 +136,7 @@ describe("Tier EI — execution-owned initialization", () => { const text = "\n'} />\n"; const execution = yield* executeInstalled( { ...inlineSource(text), includes: [], stream: new InMemoryStream() }, - [{ admissions, declarations, evaluation: { read } }], + [{ admissions, declarations }], ); const output = String(yield* collect(execution)); expect(admitted).toEqual(["original"]); diff --git a/packages/core/tests/generated-composition.test.ts b/packages/core/tests/generated-composition.test.ts index b28613657..fb2dd2859 100644 --- a/packages/core/tests/generated-composition.test.ts +++ b/packages/core/tests/generated-composition.test.ts @@ -26,7 +26,7 @@ import type { Operation } from "effection"; import { collect } from "../src/collect.ts"; import { executeInstalled } from "../host.ts"; -import { fileDeleteEntry, fileReadEntry, fileWriteEntry } from "../host.ts"; +import { useSelection } from "./support/generated-selection.ts"; import type { ExecutionInstallation } from "../host.ts"; import { retainedSource } from "../src/root-source.ts"; import { recordedFiles, useRecordedFiles } from "./support/fragment-files.ts"; @@ -40,23 +40,40 @@ const NOTE = "the retained note\n"; const OTHER = "the other note\n"; function reading(files: RecordedFiles): ExecutionInstallation { - return { evaluation: { read: [fileReadEntry()] }, install: () => useRecordedFiles(files) }; + return { + *install(): Operation { + yield* useSelection({ File: ["self-closing"] }); + yield* useRecordedFiles(files); + }, + }; } +/** + * A selection whose spellings all write. + * + * Which class a spelling reaches is the owner's answer rather than the + * selection's, so "no read table" is now exactly "no spelling whose owner + * offers it as a read": `` paired and `` are both writes + * by their own declarations. + */ function writing(files: RecordedFiles): ExecutionInstallation { return { - evaluation: { read: [], write: [fileWriteEntry(), fileDeleteEntry()] }, - install: () => useRecordedFiles(files), + *install(): Operation { + yield* useSelection({ File: ["paired"], "File.Delete": ["self-closing"] }); + yield* useRecordedFiles(files); + }, }; } function both(files: RecordedFiles): ExecutionInstallation { return { - evaluation: { - read: [fileReadEntry()], - write: [fileWriteEntry(), fileDeleteEntry()], + *install(): Operation { + yield* useSelection({ + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + }); + yield* useRecordedFiles(files); }, - install: () => useRecordedFiles(files), }; } @@ -325,14 +342,16 @@ describe("Tier FE31 — Json is composition, not an effect", () => { }); it("FE31: a host cannot give an effect to a composition name", function* () { - // Closed-world conflict detection, at profile capture rather than at - // evaluation: the host never gets to shadow core's own pure component. + // Closed-world conflict detection, at capture rather than at evaluation: + // the host never gets to shadow core's own pure component. const files = recordedFiles(); const message = yield* refusal( run(evaluating(`\n`), [ { - evaluation: { read: [{ ...fileReadEntry(), name: "Json" }] }, - install: () => useRecordedFiles(files), + *install(): Operation { + yield* useSelection({ Json: ["self-closing"] }); + yield* useRecordedFiles(files); + }, }, ]), ); @@ -369,11 +388,11 @@ function unwinding(log: string[]): FunctionComponentDefinition { }; } -/** A profile admitting that one component as a trusted component answer. */ +/** A selection offering that one name, which a provider answers for. */ function holding(definition: FunctionComponentDefinition): ExecutionInstallation { return { - evaluation: { - read: [{ kind: "component-answer", name: "Held", forms: ["self-closing"] }], + *install(): Operation { + yield* useSelection({ Held: ["self-closing"] }); }, componentAnswers: [answerProvider("Held", definition)], }; diff --git a/packages/core/tests/generated-owner-metadata.test.ts b/packages/core/tests/generated-owner-metadata.test.ts index 9c1b07658..e71f116d2 100644 --- a/packages/core/tests/generated-owner-metadata.test.ts +++ b/packages/core/tests/generated-owner-metadata.test.ts @@ -36,6 +36,7 @@ import type { Operation } from "effection"; import { collect } from "../src/collect.ts"; import { executeInstalled } from "../host.ts"; import type { ComponentAnswerInstallation, ExecutionInstallation } from "../host.ts"; +import { useSelection } from "./support/generated-selection.ts"; import { retainedSource } from "../src/root-source.ts"; import type { IdentityComponent } from "../src/invocation-identity.ts"; import { Component } from "../src/component-api.ts"; @@ -570,9 +571,13 @@ describe("Tier GOM — a declared component's offer survives capture", () => { ): ExecutionInstallation { return { components: [component], - evaluation: { read: [{ kind: "component-answer", name: "Probe", forms: ["self-closing"] }] }, componentAnswers: [publishing(published)], - ...(install === undefined ? {} : { install }), + *install(): Operation { + yield* useSelection({ Probe: ["self-closing"] }); + if (install !== undefined) { + yield* install(); + } + }, }; } diff --git a/packages/core/tests/generated-selection.test.ts b/packages/core/tests/generated-selection.test.ts new file mode 100644 index 000000000..37da7a0a2 --- /dev/null +++ b/packages/core/tests/generated-selection.test.ts @@ -0,0 +1,815 @@ +/** + * Tier GS — the component forms a host offers generated XMD (spec §5.3.2). + * + * `Component.generated` is the whole of what a trusted host contributes to + * generated evaluation: component names and authored forms. Everything else — + * the identity each spelling runs under, what its props are validated against, + * whether it observes or mutates, what documents it — comes from the + * declaration that owns the implementation. + * + * So the rows here are about the three things only canonical execution can do + * with that answer: read it once and validate all of it, resolve each selected + * name once before public execution policy can replace anything, and retain the + * implementation and its metadata together. + * + * Every row drives a real execution through the public ``. Where a + * refusal is expected the row also asks what did *not* happen — no policy, no + * root import, no producer, no effect — because a selection core could not + * capture must leave no partial allowance anywhere in the execution. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { createApi } from "@effectionx/context-api"; +import { ensure, resource, scoped, until, useScope } from "effection"; +import type { Operation } from "effection"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; + +import { collect } from "../src/collect.ts"; +import { Component } from "../src/component-api.ts"; +import type { GeneratedSelection } from "../src/component-api.ts"; +import { Execution } from "../api.ts"; +import { executeInstalled } from "../host.ts"; +import type { ExecutionInstallation } from "../host.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import { retainedSource } from "../src/root-source.ts"; +import { recordedFiles, useRecordedFiles } from "./support/fragment-files.ts"; +import type { RecordedFiles } from "./support/fragment-files.ts"; +import { answerProvider, implementation } from "./support/answer-provider.ts"; +import { useSelection } from "./support/generated-selection.ts"; +import type { ComponentDefinition, FunctionComponentDefinition } from "../src/types.ts"; + +const ROOT_PATH = "selection.md"; +const NOTE = "the retained note\n"; + +/** One document, run under one selection. */ +interface Attempt { + output?: string; + failure?: string; + events: DurableEvent[]; +} + +/** + * Run one document with this selection offered in the executing scope. + * + * `offered` is passed as the contextual answer rather than through an + * installation, because that is what a host composes: there is no installation + * member for it, and core reads the final answer of the chain once. + */ +function run( + source: string, + options: { + offered?: unknown; + installations?: readonly ExecutionInstallation[]; + files?: RecordedFiles; + events?: DurableEvent[]; + around?: () => Operation; + /** What the *host* supplies, before any policy has seen the request. */ + includes?: readonly string[]; + } = {}, +): Operation { + return scoped(function* () { + const stream = new InMemoryStream(options.events ?? []); + if (options.files !== undefined) { + yield* useRecordedFiles(options.files); + } + if (options.offered !== undefined) { + // Deliberately untyped at the seam: a host crosses this boundary from + // JavaScript, so the rows that state a malformed selection state one. + const stated = options.offered; + yield* Component.around({ generated: () => stated as GeneratedSelection }); + } + if (options.around !== undefined) { + yield* options.around(); + } + try { + const output = yield* collect( + yield* executeInstalled( + { + ...retainedSource(ROOT_PATH, source), + stream, + includes: [...(options.includes ?? [])], + }, + [...(options.installations ?? [])], + ), + ); + return { output: String(output), events: yield* stream.readAll() }; + } catch (error) { + return { + failure: error instanceof Error ? error.message : String(error), + events: yield* stream.readAll(), + }; + } + }); +} + +/** One fragment, as the authored element that evaluates it. */ +function evaluating(fragment: string, allow: readonly string[] = ["read"]): string { + return ( + `\n\n\n` + ); +} + +/** The paired producer form, which renders the program it then runs. */ +function producing(fragment: string): string { + return ( + `\n\n` + + `\n\n\n` + ); +} + +/** A component that renders the program a producer form supplies. */ +function useProducer(produced: string[]): Operation { + return registerComponents([ + { + name: "Produce", + origin: "test://produce", + props: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props: Record): Operation { + produced.push(String(props.text)); + return String(props.text); + }, + }, + ]); +} + +/** A directory holding one Markdown component, reachable only by search path. */ +function useComponentDirectory(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-u5-includes-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* writeTextFile(join(dir, "Replaced.md"), "REPLACED-COMPONENT\n"); + yield* provide(dir); + }); +} + +function admissions(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === "generated_xmd", + ); +} + +function rootImports(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => + event.type === "yield" && + event.description.type === "import_component" && + event.description.name === "__root__", + ); +} + +describe("Tier GS — the property and its default", () => { + it("U5-1: the default is absent, and a document that writes no `` runs", function* () { + const read = yield* scoped(function* () { + return yield* Component.operations.generated; + }); + expect(read).toBe(undefined); + + const attempt = yield* run("The document wrote no fragment.\n"); + expect(attempt.failure).toBe(undefined); + expect(attempt.output).toContain("The document wrote no fragment."); + expect(admissions(attempt.events)).toHaveLength(0); + }); + + it("U5-1: a same-name Component descriptor answers the same default", function* () { + // The property is a value member of the stable `Component` Api, so a second + // descriptor built with the same name shares every installed handler and + // owns its own default. With nothing installed, both answer absence. + const other = createApi<{ generated: GeneratedSelection | undefined }>("Component", { + generated: undefined, + }); + expect(yield* other.operations.generated).toBe(undefined); + expect(yield* Component.operations.generated).toBe(undefined); + }); + + it("U5-1: an absent selection refuses a paired `` before its producer runs", function* () { + const produced: string[] = []; + + const attempt = yield* run(producing(`\n`), { + around: () => useProducer(produced), + }); + + expect(attempt.failure).toContain("no generated components here"); + // The producer renders the program, so a refusal that happened after it + // would have run an Agent turn for a fragment nothing could admit. + expect(produced).toEqual([]); + expect(admissions(attempt.events)).toHaveLength(0); + }); +}); + +describe("Tier GS — a selection core cannot capture refuses setup", () => { + /** Every malformed selection a host can offer, and the word it refuses with. */ + const MALFORMED: readonly (readonly [string, unknown, string])[] = [ + ["an empty selection", {}, "selected no component"], + ["a selection that is not a map", ["File"], "other than a map"], + ["a name that is not a component name", { "not a name": ["self-closing"] }, "component name"], + ["a form list that is not a list", { File: "self-closing" }, "other than a list"], + ["an empty form list", { File: [] }, "the accepted declarations are"], + ["a form this engine does not have", { File: ["either"] }, "not an authored form"], + ["one form written twice", { File: ["paired", "paired"] }, "the accepted declarations are"], + [ + "a reversed pair", + { File: ["paired", "self-closing"] }, + "a pair is written self-closing first", + ], + ["a name nothing resolves", { Absent: ["self-closing"] }, "Cannot resolve component"], + [ + "a spelling its owner offers no generated use of", + { "File.Delete": ["paired"] }, + "offers no generated use of it", + ], + ["a name core's own composition holds", { Json: ["self-closing"] }, "trusted composition"], + ]; + + for (const [what, offered, diagnostic] of MALFORMED) { + it(`U5-1: ${what} refuses before policy, the root import and any effect`, function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const policy: string[] = []; + const produced: string[] = []; + + const attempt = yield* run(producing(`\n`), { + offered, + files, + *around(): Operation { + yield* useProducer(produced); + yield* Execution.around({ + *execute([request], next) { + policy.push("execute"); + yield* next(request); + }, + }); + }, + }); + + expect([what, attempt.failure ?? ""]).toEqual([what, expect.stringContaining(diagnostic)]); + // Nothing after setup happened: no public execution policy, no root + // import, no producer, no effect and no admission. + expect([what, policy]).toEqual([what, []]); + expect([what, rootImports(attempt.events)]).toEqual([what, []]); + expect([what, produced]).toEqual([what, []]); + expect([what, files.performed]).toEqual([what, []]); + expect([what, admissions(attempt.events)]).toEqual([what, []]); + }); + } + + it("U5-1: a valid selection is the control every refusal above is measured against", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const policy: string[] = []; + const produced: string[] = []; + + const attempt = yield* run(producing(`\n`), { + offered: { File: ["self-closing"] }, + files, + *around(): Operation { + yield* useProducer(produced); + yield* Execution.around({ + *execute([request], next) { + policy.push("execute"); + yield* next(request); + }, + }); + }, + }); + + expect(attempt.failure).toBe(undefined); + expect(attempt.output).toContain("the retained note"); + expect(policy).toEqual(["execute"]); + expect(rootImports(attempt.events)).toHaveLength(1); + expect(produced).toHaveLength(1); + expect(files.performed).toEqual(["read notes.md"]); + expect(admissions(attempt.events)).toHaveLength(1); + }); + + it("U5-1: a mixed valid and invalid selection installs no partial allowance", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + + // One name this host could legitimately offer, and one it could not. The + // batch is validated whole, so the valid member is not installed on its + // own: nothing is filtered, repaired, or kept. + const attempt = yield* run(evaluating(`\n`), { + offered: { File: ["self-closing"], "not a name": ["self-closing"] }, + files, + }); + + expect(attempt.failure).toContain("component name"); + expect(files.performed).toEqual([]); + expect(admissions(attempt.events)).toHaveLength(0); + }); +}); + +describe("Tier GS — the selection is read once, before public policy", () => { + it("U5-2: init and the installation callbacks settle the selection before it is read", function* () { + const order: string[] = []; + const files = recordedFiles({ "notes.md": NOTE }); + + const attempt = yield* run(evaluating(`\n`), { + installations: [ + { + *install(): Operation { + order.push("install"); + // Offered here, and observed where core reads it: the handler + // records the read so the row can state *when* it happened rather + // than only what it answered. + yield* Component.around({ + generated: (_args, next) => { + order.push("read"); + return next(); + }, + }); + yield* useSelection({ File: ["self-closing"] }); + }, + }, + ], + files, + *around(): Operation { + yield* Execution.around({ + *init(_args, next) { + order.push("init"); + yield* next(); + }, + *execute([request], next) { + order.push("policy"); + // The selection is already captured. A policy that offers a broader + // one changes nothing: it is composing into a chain core has + // already read its one answer from. + yield* Component.around({ + generated: () => ({ File: ["self-closing", "paired"], Glob: ["self-closing"] }), + }); + yield* next(request); + }, + }); + }, + }); + + expect(attempt.failure).toBe(undefined); + // The order is the contract: initialization and the captured installation + // callbacks assemble the host, then core reads the selection once, and only + // then does public execution policy run. A read that happened after the + // policy would be reading a chain the policy had already composed into. + expect(order).toEqual(["init", "install", "read", "policy"]); + // The fragment reached the read it was offered. + expect(files.performed).toEqual(["read notes.md"]); + // And not the vocabulary the policy tried to add: a widened selection after + // capture is a selection nothing reads. + const widened = yield* run(evaluating(`\n`), { + installations: [ + { + *install(): Operation { + yield* useSelection({ File: ["self-closing"] }); + }, + }, + ], + *around(): Operation { + yield* Execution.around({ + *execute([request], next) { + yield* Component.around({ generated: () => ({ Glob: ["self-closing"] }) }); + yield* next(request); + }, + }); + }, + }); + expect(widened.failure).toContain("did not admit"); + }); + + it("U5-2: one property read and one import per selected name", function* () { + const reads: string[] = []; + const lookups: string[] = []; + const files = recordedFiles({ "notes.md": NOTE }); + + const attempt = yield* run(evaluating(`\n\n`), { + files, + installations: [ + { + *install(): Operation { + yield* Component.around({ + generated: (_args, next) => { + reads.push("generated"); + return next(); + }, + *importComponent([name, position], next) { + lookups.push(name); + return yield* next(name, position); + }, + }); + yield* useSelection({ File: ["self-closing", "paired"] }); + }, + }, + ], + }); + + expect(attempt.failure).toBe(undefined); + // One read of the property for the whole execution. + expect(reads).toEqual(["generated"]); + // And one *resolution* for `File`, however many spellings were selected: + // capture resolves the name once, before the root import, and one name is + // one component. The generated imports the fragment then makes are observed + // through the same chain and answered by canonical execution, so they are + // not resolutions — which is why the count is taken at the boundary rather + // than over the whole run. + const resolved = lookups.slice(0, lookups.indexOf("__root__")); + expect(resolved).toEqual(["File"]); + // The fragment wrote the name twice and performed both reads, so the single + // resolution is not a fragment that ran nothing. + expect(lookups.filter((name) => name === "File")).toHaveLength(3); + expect(files.performed).toEqual(["read notes.md", "read notes.md"]); + }); + + it("U5-2: a policy's search-path replacement reaches authored imports, not the capture", function* () { + // The one search path a document here can import a component from, and the + // host supplies none of it: everything authored resolves through whatever + // the accepted request names. + const dir = yield* useComponentDirectory(); + const files = recordedFiles({ "notes.md": NOTE }); + + const attempt = yield* run(`\n\n${evaluating(`\n`)}`, { + files, + installations: [ + { + *install(): Operation { + yield* useSelection({ File: ["self-closing"] }); + }, + }, + ], + *around(): Operation { + yield* Execution.around({ + *execute([request], next) { + // A supported policy change, made before delegating. The options + // the terminal accepts are the ones the document is executed + // under, so this is where its authored imports resolve from. + yield* next(request.withOptions({ ...request.options, includes: [dir] })); + }, + }); + }, + }); + + expect(attempt.failure).toBe(undefined); + // The authored import resolved through the replaced path. Without the + // accepted options reaching ordinary resolution there is nowhere for + // `` to come from. + expect(attempt.output).toContain("REPLACED-COMPONENT"); + // And the capture is untouched by it: the fragment wrote the one spelling + // the host selected, resolved before the policy ran. + expect(files.performed).toEqual(["read notes.md"]); + + // The other half: a path a policy added is not a vocabulary a fragment + // gained. `` is importable by the *document* and admitted for + // nothing. + const widened = yield* run(evaluating(`\n`), { + installations: [ + { + *install(): Operation { + yield* useSelection({ File: ["self-closing"] }); + }, + }, + ], + *around(): Operation { + yield* Execution.around({ + *execute([request], next) { + yield* next(request.withOptions({ ...request.options, includes: [dir] })); + }, + }); + }, + }); + expect(widened.failure).toContain("did not admit"); + }); + + it("U5-2: a later ordinary authored registration still behaves contextually", function* () { + const told: string[] = []; + + // The selection is captured; an ordinary registration a document policy + // makes afterwards is an ordinary registration, visible to the authored + // document exactly as it always was. + const attempt = yield* run("\n", { + offered: { File: ["self-closing"] }, + *around(): Operation { + yield* Execution.around({ + *execute([request], next) { + yield* registerComponents([ + { + name: "Later", + origin: "test://later", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + told.push("later ran"); + return "the later registration ran"; + }, + }, + ]); + yield* next(request); + }, + }); + }, + }); + + expect(attempt.failure).toBe(undefined); + expect(told).toEqual(["later ran"]); + expect(attempt.output).toContain("the later registration ran"); + }); +}); + +describe("Tier GS — what a selected name resolves to", () => { + it("U5-3: a registered function and a published provider answer both run", function* () { + const ran: string[] = []; + const A = implementation("Open", "the provider's answer ran"); + + const attempt = yield* run(evaluating("\n\n"), { + installations: [ + { + *install(): Operation { + yield* useSelection({ Local: ["self-closing"], Open: ["self-closing"] }); + yield* registerComponents([localComponent(ran)]); + }, + componentAnswers: [answerProvider("Open", A.definition)], + }, + ], + }); + + expect(attempt.failure).toBe(undefined); + expect(ran).toEqual(["local ran"]); + expect(attempt.output).toContain("the local registration ran"); + expect(attempt.output).toContain("the provider's answer ran"); + }); + + it("U5-3: an unpublished replacement refuses", function* () { + const ran: string[] = []; + const replacement: FunctionComponentDefinition = { + kind: "function", + name: "Local", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: { + "self-closing": { + identity: "https://executable.md/components/local/read", + effect: "read", + }, + }, + // deno-lint-ignore require-yield + *fn(): Operation { + ran.push("replacement ran"); + return "the replacement ran"; + }, + }; + + // Ordinary import middleware answering with its own definition, publishing + // nothing. It is a valid ordinary answer and it is not an implementation + // this execution may retain for a selected name. + const attempt = yield* run(evaluating("\n"), { + installations: [ + { + *install(): Operation { + yield* useSelection({ Local: ["self-closing"] }); + yield* registerComponents([localComponent(ran)]); + yield* Component.around({ + *importComponent([name, position], next) { + return name === "Local" ? replacement : yield* next(name, position); + }, + }); + }, + }, + ], + }); + + expect(attempt.failure).toContain("neither canonical resolution's own answer"); + expect(ran).toEqual([]); + }); + + it("U5-3: a replacement offering no generated use of the selected spelling refuses", function* () { + const ran: string[] = []; + + const attempt = yield* run(evaluating("\n"), { + installations: [ + { + *install(): Operation { + yield* useSelection({ Open: ["self-closing"] }); + }, + componentAnswers: [ + answerProvider("Open", { + kind: "function", + name: "Open", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + // deno-lint-ignore require-yield + *fn(): Operation { + ran.push("unoffered ran"); + return "the unoffered answer ran"; + }, + }), + ], + }, + ], + }); + + expect(attempt.failure).toContain("offers no generated use of it"); + expect(ran).toEqual([]); + }); + + it("U5-3: an unsupported answer refuses", function* () { + // A repository Markdown component is a legitimate answer for an authored + // import and is not something a generated fragment can be given: there is + // no implementation to invoke and no declaration to read an identity off. + const attempt = yield* run(evaluating("\n"), { + installations: [ + { + *install(): Operation { + yield* useSelection({ Markdowny: ["self-closing"] }); + yield* Component.around({ + *importComponent([name, position], next) { + if (name !== "Markdowny") { + return yield* next(name, position); + } + return markdownAnswer(); + }, + }); + }, + }, + ], + }); + + expect(attempt.failure).toBeDefined(); + expect(attempt.failure).not.toContain("rendered"); + }); + + it("U5-3: a definition edited after publication cannot be rebaselined", function* () { + const ran: string[] = []; + const definition: FunctionComponentDefinition = { + kind: "function", + name: "Edited", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: { + "self-closing": { + identity: "https://executable.md/components/edited/read", + effect: "read", + }, + }, + // deno-lint-ignore require-yield + *fn(): Operation { + ran.push("edited ran"); + return "the edited answer ran"; + }, + }; + + const attempt = yield* run(evaluating("\n"), { + installations: [ + { + *install(): Operation { + yield* useSelection({ Edited: ["self-closing"] }); + }, + componentAnswers: [ + { + origin: "test://edited", + *install(registrar): Operation { + yield* registrar.around(function* (request, next) { + if (request.name !== "Edited") { + return yield* next(); + } + const published = request.answer(definition); + // Published, and then edited in place. What the execution + // retained is the copy taken at publication, so this reaches + // nothing — and the answer the chain is now holding no longer + // describes what was published, which refuses. + Object.assign(definition, { + generated: { + "self-closing": { + identity: "https://executable.md/components/edited/write", + effect: "write", + }, + }, + }); + return published; + }); + }, + }, + ], + }, + ], + }); + + expect(attempt.failure).toBeDefined(); + expect(ran).toEqual([]); + expect(admissions(attempt.events)).toHaveLength(0); + }); + + it("U5-3: accessor-backed metadata is refused before it can answer twice", function* () { + const readings: string[] = []; + // A declaration whose metadata would answer one thing while it is validated + // and another when it is read again. The reading that was validated is the + // reading that is retained, so a value that behaved once is not a value + // this execution may retain — and it never gets a second turn to prove it. + const definition = { + kind: "function" as const, + name: "Shifty", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"] as const, + get generated() { + readings.push("read"); + return readings.length === 1 + ? { + "self-closing": { + identity: "https://executable.md/components/shifty/read", + effect: "read" as const, + }, + } + : { + "self-closing": { + identity: "https://executable.md/components/shifty/write", + effect: "write" as const, + }, + }; + }, + // deno-lint-ignore require-yield + *fn(): Operation { + return "the shifty answer ran"; + }, + }; + + const attempt = yield* run(evaluating("\n"), { + installations: [ + { + *install(): Operation { + yield* useSelection({ Shifty: ["self-closing"] }); + }, + componentAnswers: [answerProvider("Shifty", definition)], + }, + ], + }); + + expect(attempt.failure).toBeDefined(); + expect(attempt.output ?? "").not.toContain("the shifty answer ran"); + expect(admissions(attempt.events)).toHaveLength(0); + // At most one reading, which is the whole of the protection: what the check + // was made about is what would have been retained, and the second answer + // this accessor was waiting to give is never asked for. + expect(readings.length).toBeLessThanOrEqual(1); + }); + + it("U5-3: the retained admission holds the identity the owner declared", function* () { + const ran: string[] = []; + + // The positive control for the two refusals above: an ordinary declaration + // is retained with the URL its own metadata states, and that is the row the + // admission records. + const attempt = yield* run(evaluating("\n"), { + installations: [ + { + *install(): Operation { + yield* useSelection({ Local: ["self-closing"] }); + yield* registerComponents([localComponent(ran)]); + }, + }, + ], + }); + + expect(attempt.failure).toBe(undefined); + expect(ran).toEqual(["local ran"]); + const admission = admissions(attempt.events)[0]; + expect(JSON.stringify(admission)).toContain("https://executable.md/components/local/read"); + yield* useScope(); + }); +}); + +/** A Markdown definition, which is a legitimate answer and not an invocable one. */ +function markdownAnswer(): ComponentDefinition { + return { + kind: "markdown", + name: "Markdowny", + path: "Markdowny.md", + meta: {}, + props: { type: "object", properties: {}, additionalProperties: false }, + bodySegments: [], + }; +} + +/** One ordinary registration a selection may name. */ +function localComponent(ran: string[]): FunctionComponentDefinition & { origin: string } { + return { + kind: "function", + name: "Local", + origin: "test://local", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: { + "self-closing": { identity: "https://executable.md/components/local/read", effect: "read" }, + }, + // deno-lint-ignore require-yield + *fn(): Operation { + ran.push("local ran"); + return "the local registration ran"; + }, + }; +} diff --git a/packages/core/tests/generated-xmd.test.ts b/packages/core/tests/generated-xmd.test.ts index ab19e0d74..f2a3e0117 100644 --- a/packages/core/tests/generated-xmd.test.ts +++ b/packages/core/tests/generated-xmd.test.ts @@ -50,6 +50,11 @@ import { SOURCE_POSITION_FIELD } from "../src/source-position.ts"; import { useSecretScannerFactory } from "../src/secrets/policy.ts"; import { createSecretScanner } from "../src/secrets/scanner.ts"; import type { SecretScanner } from "../src/secrets/scanner.ts"; +// The evaluator itself, which is core's internal boundary rather than +// something a host reaches: a trusted host contributes one `Component.generated` +// selection and canonical execution drives this. These rows drive it directly, +// which is what keeps the mechanism's own refusals separable from the capture +// that normally supplies its tables. import { evaluateGeneratedXmd, pinnedComponent, @@ -58,17 +63,16 @@ import { pinnedFileRead, pinnedFileWrite, pinnedMutation, -} from "../host.ts"; -import { executeInstalled } from "../host.ts"; +} from "../src/generated-xmd.ts"; import type { - DurablePreparation, - ExecutionInstallation, GeneratedComponentForm, GeneratedEffectClass, GeneratedMutation, GeneratedObservation, GeneratedXmdRequest, -} from "../host.ts"; +} from "../src/generated-xmd.ts"; +import { executeInstalled } from "../host.ts"; +import type { DurablePreparation, ExecutionInstallation } from "../host.ts"; import type { GeneratedEffect, GeneratedForms } from "../src/components/generated-metadata.ts"; import type { FunctionComponentDefinition, Json, JsonObject } from "../src/types.ts"; @@ -96,9 +100,13 @@ const COMPOSITION_RECORD: JsonObject = { ...COMPOSITION[0] }; const ROOT_SOURCE = "The host ran a generated fragment.\n"; const URL_ONE = "https://api.example.test/one"; const URL_TWO = "https://api.example.test/two"; +/** + * Workspace roots a marker-3 record carried. + * + * Kept only so a hostile-history row can state that shape and be refused: + * nothing this build writes or reads holds a Workspace basis. + */ const ROOTS = ["workspace://primary", "workspace://secondary"]; -/** A root the run retains after the admission — its own legitimate progress. */ -const ADVANCED = "workspace://advanced"; /** * What one host component's declaration offers a spelling as. @@ -271,13 +279,7 @@ function request( source: string, observations: readonly GeneratedObservation[], ): GeneratedXmdRequest { - return { - id: "turn-1", - source, - workspaceRoots: ROOTS, - selectedRoot: ROOTS[0] ?? "", - observations, - }; + return { id: "turn-1", source, observations }; } /** What one host-driven evaluation produced. */ @@ -406,7 +408,7 @@ describe("Tier GX — the trusted-host seam", () => { expect(attempt.output).toContain("probed"); }); - it("GX2: records one admission naming the source, the roots and the identities", function* () { + it("GX2: records one admission naming the source and the selected identities", function* () { const attempt = yield* evaluate(request("\n", [probe()])); const [admission] = admissions(attempt.events); @@ -414,12 +416,12 @@ describe("Tier GX — the trusted-host seam", () => { throw new Error("the run recorded no generated-XMD admission"); } expect(admission.description.name).toBe("generated:turn-1"); - // The current shape: marked, and carrying the Workspace basis as one - // member rather than two top-level ones, because a host may have none. One - // row per admitted spelling, each holding the URL its owner offers that - // spelling under, what kind of thing answered for the name, and which + // The current shape: marked, and holding the selection and nothing live. + // One row per admitted spelling, each carrying the URL its owner offers + // that spelling under, what kind of thing answered for the name, and which // effect table admitted it — so an admission that moved says which - // spelling of which name moved. + // spelling of which name moved. There is no Workspace basis and no request + // inventory: what a request may reach is decided where it is performed. const PROBE_ROW = { name: "Probe", form: "self-closing", @@ -427,9 +429,9 @@ describe("Tier GX — the trusted-host seam", () => { kind: "component-answer", effect: "read", }; - expect(admission.description.input).toMatchObject({ - version: 3, - workspace: { roots: ROOTS, selectedRoot: ROOTS[0] }, + expect(admission.description.input).toEqual({ + version: 4, + allow: ["read"], allowed: [...COMPOSITION, PROBE_ROW], }); expect(admission.result).toMatchObject({ @@ -441,10 +443,9 @@ describe("Tier GX — the trusted-host seam", () => { // Retained in the result as well as the input, because durable replay // matches an effect by type and name and never compares a description. policy: { - version: 3, - workspace: { roots: ROOTS, selectedRoot: ROOTS[0] }, + version: 4, + allow: ["read"], allowed: [...COMPOSITION, PROBE_ROW], - requests: [], }, }, }); @@ -465,7 +466,7 @@ describe("Tier GX — the complete fragment is read first", () => { it(`GX3: an unsafe construct ${where} costs no request and no admission`, function* () { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - const attempt = yield* evaluate(request(source, [pinnedFetch([ADMITTED_REQUEST]), probe()])); + const attempt = yield* evaluate(request(source, [pinnedFetch(), probe()])); expect(transport.performed).toHaveLength(0); expect(admittedFragments(attempt.events)).toHaveLength(0); @@ -507,7 +508,7 @@ describe("Tier GX — the complete fragment is read first", () => { it(`GX4: refuses ${what} before any effect`, function* () { const transport = yield* useTransport(() => ({ status: 200 })); - const attempt = yield* evaluate(request(source, [pinnedFetch([ADMITTED_REQUEST]), probe()])); + const attempt = yield* evaluate(request(source, [pinnedFetch(), probe()])); expect(transport.performed).toHaveLength(0); expect(admittedFragments(attempt.events)).toHaveLength(0); @@ -529,7 +530,7 @@ describe("Tier GX — the complete fragment is read first", () => { const source = `\n\n\n`; const stream = new InMemoryStream(); const live = yield* useTransport(() => ({ status: 200, body: "answer" })); - const first = yield* evaluate(request(source, [pinnedFetch([ADMITTED_REQUEST])]), { stream }); + const first = yield* evaluate(request(source, [pinnedFetch()]), { stream }); // One authored element, entered twice, so two durable effects. expect(live.performed).toHaveLength(2); @@ -544,7 +545,7 @@ describe("Tier GX — the complete fragment is read first", () => { const partial = events.slice(0, cut + 1); const resumedTransport = yield* useTransport(() => ({ status: 200, body: "answer" })); const before = resumedTransport.performed.length; - const resumed = yield* evaluate(request(source, [pinnedFetch([ADMITTED_REQUEST])]), { + const resumed = yield* evaluate(request(source, [pinnedFetch()]), { stream: new InMemoryStream(partial), }); @@ -561,7 +562,7 @@ describe("Tier GX — the complete fragment is read first", () => { const attempt = yield* evaluate( request("\n\n```bash exec\nprintf ran\n```\n\n\n", [ reads(), - pinnedFetch([ADMITTED_REQUEST]), + pinnedFetch(), probe(), ]), ); @@ -603,10 +604,7 @@ describe("Tier GX — only pinned identities execute", () => { yield* writeTextFile(join(workspace, "Fetch.md"), "the repository Fetch ran.\n"); const attempt = yield* evaluate( - request(`\n\n\n`, [ - pinnedFetch([ADMITTED_REQUEST]), - probe(), - ]), + request(`\n\n\n`, [pinnedFetch(), probe()]), { includes: [workspace] }, ); @@ -688,10 +686,7 @@ describe("Tier GX — only pinned identities execute", () => { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); const attempt = yield* evaluate( - request(`\n\n\n`, [ - pinnedFetch([ADMITTED_REQUEST]), - probe(), - ]), + request(`\n\n\n`, [pinnedFetch(), probe()]), { installations: [installation] }, ); @@ -731,97 +726,30 @@ describe("Tier GX — the request a generated fragment may perform", () => { it("GX11: the exact admitted request performs once", function* () { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - const attempt = yield* evaluate( - request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]), - ); + const attempt = yield* evaluate(request(`\n`, [pinnedFetch()])); expect(attempt.failure).toBe(undefined); expect(transport.performed).toEqual([{ url: URL_ONE, init: { method: "GET", headers: {} } }]); expect(observations(attempt.events)).toHaveLength(1); }); - const CEILING: Record = { - url: URL_ONE, - method: "GET", - headers: { accept: "application/json" }, - timeout: "30s", - }; - - const MISMATCHES: Array<[string, string]> = [ - [ - "a different scheme", - ``, - ], - [ - "a different host", - ``, - ], - [ - "a different path", - ``, - ], - [ - "a different method", - ``, - ], - [ - "a different header value", - ``, - ], - [ - "an extra header", - ``, - ], - ["a missing header", ``], - [ - "a different timeout", - ``, - ], - [ - "no timeout at all", - ``, - ], - ]; - - for (const [what, element] of MISMATCHES) { - it(`GX12: ${what} performs no request`, function* () { - const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - - const attempt = yield* evaluate(request(`${element}\n`, [pinnedFetch([CEILING])])); - - expect(transport.performed).toHaveLength(0); - expect(admittedFragments(attempt.events)).toHaveLength(0); - expect(refusals(attempt.events)).toHaveLength(1); - expect(attempt.failure).toContain("did not admit"); - }); - } - - it("GX13: the exact ceiling still admits the request it describes", function* () { - const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - - const attempt = yield* evaluate( - request( - `\n`, - [pinnedFetch([CEILING])], - ), - ); - - expect(attempt.failure).toBe(undefined); - expect(transport.performed).toEqual([ - { - url: URL_ONE, - init: { method: "GET", headers: { accept: "application/json" }, timeout: 30_000 }, - }, - ]); - }); - + /** + * What a request may *reach* is no longer this boundary's question. + * + * The evaluator admits names and forms; which destination, method and headers + * an admitted `` may actually send is decided by the effective + * `Permissions` of the scope that performs it, after every `API.Fetch` + * transformation and immediately before transport. So a fragment whose data + * expressions produce a dynamic destination is an ordinary input here, and the + * rows that prove a forbidden destination, method or header reaches zero + * transport calls live where that decision is made — `shared-operation-permissions` + * Tier PRM, against a host that states such a bound. + */ it("GX13b: two admitted reads in one fragment are two observations, not one replayed twice", function* () { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); const attempt = yield* evaluate( - request(`\n\n\n`, [ - pinnedFetch([{ url: URL_ONE }, { url: URL_TWO }]), - ]), + request(`\n\n\n`, [pinnedFetch()]), ); expect(attempt.failure).toBe(undefined); @@ -832,81 +760,6 @@ describe("Tier GX — the request a generated fragment may perform", () => { expect(names).toHaveLength(2); expect(new Set(names).size).toBe(2); }); - - it("GX14: admitting Fetch without a ceiling is refused before any evaluation", function* () { - expect(() => pinnedFetch([])).toThrow(); - }); - - /** - * One admitted read that states a ceiling and records what it was asked. - * - * Two of these make the pair these rows need: each entry states its own - * ceiling, and the run keys its ceiling table by the entry itself rather than - * by anything describing it — so one entry's limit is never the other's, - * however alike two entries look. - */ - function bounded(name: string, url: string, performed: string[]): GeneratedObservation { - return { - name, - kind: "component-answer", - requests: [{ url }], - definition: { - kind: "function", - name, - forms: ["self-closing"], - generated: offers(`${name.toLowerCase()}/read`, "read"), - props: { - type: "object", - properties: { url: { type: "string" } }, - required: ["url"], - additionalProperties: false, - }, - // deno-lint-ignore require-yield - *fn(props: Record): Operation { - performed.push(`${name} ${String(props.url)}`); - return name; - }, - }, - }; - } - - it("GX14b: two admitted reads keep their own ceilings", function* () { - const performed: string[] = []; - const attempt = yield* evaluate( - request(`\n\n\n`, [ - bounded("One", URL_ONE, performed), - bounded("Other", URL_TWO, performed), - ]), - ); - - // Each element performed the request its own entry stated. A shared - // ceiling would have left one of the two holding the other's limit, and one - // of these two admitted requests would have been refused. - expect(attempt.failure).toBe(undefined); - expect(performed).toEqual([`One ${URL_ONE}`, `Other ${URL_TWO}`]); - // And the retained policy holds both limits rather than one of them twice. - const policy = recordedPolicy(admittedFragments(attempt.events)[0]); - expect(isRecord(policy) ? policy.requests : undefined).toEqual([ - { url: URL_ONE, method: "GET", headers: {} }, - { url: URL_TWO, method: "GET", headers: {} }, - ]); - }); - - it("GX14c: one entry's ceiling never admits the other's request", function* () { - const performed: string[] = []; - const attempt = yield* evaluate( - request(`\n`, [ - bounded("One", URL_ONE, performed), - bounded("Other", URL_TWO, performed), - ]), - ); - - // The negative half of the pair above. `` may perform exactly what - // its own entry stated, and the other entry's limit is not its own. - expect(attempt.failure).toContain("did not admit"); - expect(admittedFragments(attempt.events)).toHaveLength(0); - expect(performed).toEqual([]); - }); }); describe("Tier GX — what the run keeps", () => { @@ -924,10 +777,10 @@ describe("Tier GX — what the run keeps", () => { it("GX15: a partial replay restores the admission and performs no second request", function* () { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - const first = yield* evaluate(request(SOURCE, [pinnedFetch([ADMITTED_REQUEST])])); + const first = yield* evaluate(request(SOURCE, [pinnedFetch()])); expect(transport.performed).toHaveLength(1); - const again = yield* evaluate(request(SOURCE, [pinnedFetch([ADMITTED_REQUEST])]), { + const again = yield* evaluate(request(SOURCE, [pinnedFetch()]), { stream: partial(first.events), }); @@ -940,10 +793,10 @@ describe("Tier GX — what the run keeps", () => { it("GX16: a completed replay restores the root result and repeats no observation", function* () { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - const first = yield* evaluate(request(SOURCE, [pinnedFetch([ADMITTED_REQUEST])])); + const first = yield* evaluate(request(SOURCE, [pinnedFetch()])); expect(transport.performed).toHaveLength(1); - const again = yield* evaluate(request(SOURCE, [pinnedFetch([ADMITTED_REQUEST])]), { + const again = yield* evaluate(request(SOURCE, [pinnedFetch()]), { stream: new InMemoryStream(first.events), }); @@ -992,9 +845,7 @@ describe("Tier GX — what the run keeps", () => { const stream = new InMemoryStream(); const attempt = yield* evaluate( - request(`\n\n\n`, [ - pinnedFetch([{ url: URL_ONE }, { url: URL_TWO }]), - ]), + request(`\n\n\n`, [pinnedFetch()]), { stream }, ); @@ -1010,9 +861,7 @@ describe("Tier GX — what the run keeps", () => { // the second element never ran rather than because the first was kept. const transport = yield* useTransport(() => ({ status: 200, body: "answered" })); const attempt = yield* evaluate( - request(`\n\n\n`, [ - pinnedFetch([{ url: URL_ONE }, { url: URL_TWO }]), - ]), + request(`\n\n\n`, [pinnedFetch()]), ); expect(attempt.failure).toBe(undefined); @@ -1036,9 +885,7 @@ describe("Tier GX — what the run keeps", () => { }); const installation = driven(function* () { yield* evaluateGeneratedXmd( - request(`\n\n\n`, [ - pinnedFetch([{ url: URL_ONE }, { url: URL_TWO }]), - ]), + request(`\n\n\n`, [pinnedFetch()]), ); }); yield* collect( @@ -1081,7 +928,7 @@ describe("Tier GX — what the run keeps", () => { yield* scoped(function* () { const running = yield* spawn(function* () { const installation = driven(function* () { - yield* evaluateGeneratedXmd(request(SOURCE, [pinnedFetch([ADMITTED_REQUEST])])); + yield* evaluateGeneratedXmd(request(SOURCE, [pinnedFetch()])); }); yield* collect( yield* executeInstalled( @@ -1099,7 +946,7 @@ describe("Tier GX — what the run keeps", () => { expect(admissions(interrupted)).toHaveLength(1); expect(observations(interrupted)).toHaveLength(0); - const continued = yield* evaluate(request(SOURCE, [pinnedFetch([ADMITTED_REQUEST])]), { + const continued = yield* evaluate(request(SOURCE, [pinnedFetch()]), { stream: new InMemoryStream(interrupted), }); @@ -1183,8 +1030,7 @@ describe("Tier GX — nested generated effects belong to the owning expansion", it("GX18a: a generated read and a later parent marker replay as one offered sequence", function* () { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); const executed: string[] = []; - const candidate = () => - request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]); + const candidate = () => request(`\n`, [pinnedFetch()]); const first = yield* evaluate(candidate(), { after: marker(executed) }); expect(first.failure).toBe(undefined); @@ -1197,16 +1043,12 @@ describe("Tier GX — nested generated effects belong to the owning expansion", "import_component", ]); - // The continuation runs after the run's own progress: one more retained - // root, and the run stands on it. The admission's basis is still retained, - // so the same sequence is offered and restores. - const again = yield* evaluate( - { ...candidate(), workspaceRoots: [...ROOTS, ADVANCED], selectedRoot: ADVANCED }, - { - after: marker(executed), - stream: partial(first.events), - }, - ); + // The continuation runs after the run's own progress, so the same sequence + // is offered and restores. + const again = yield* evaluate(candidate(), { + after: marker(executed), + stream: partial(first.events), + }); expect(again.failure).toBe(undefined); expect(again.output).toBe(first.output); @@ -1245,14 +1087,11 @@ describe("Tier GX — nested generated effects belong to the owning expansion", ]); // The continuation runs after the run's own progress — the very progress a - // committed generated write makes: one more retained root, now current. - const again = yield* evaluate( - { ...candidate(), workspaceRoots: [...ROOTS, ADVANCED], selectedRoot: ADVANCED }, - { - after: marker(executed), - stream: partial(first.events), - }, - ); + // committed generated write makes. + const again = yield* evaluate(candidate(), { + after: marker(executed), + stream: partial(first.events), + }); expect(again.failure).toBe(undefined); expect(again.output).toBe(first.output); @@ -1268,7 +1107,7 @@ describe("Tier GX — nested generated effects belong to the owning expansion", it("GX18c: an empty fragment keeps its output and invents no nested effect on replay", function* () { const executed: string[] = []; - const candidate = () => request("", [pinnedFetch([ADMITTED_REQUEST])]); + const candidate = () => request("", [pinnedFetch()]); const first = yield* evaluate(candidate(), { after: marker(executed) }); expect(first.failure).toBe(undefined); @@ -1324,138 +1163,21 @@ describe("Tier GX — nested generated effects belong to the owning expansion", }); }); -describe("Tier GX — a malformed generated request reports its class, not itself", () => { - beforeAll(() => useTempFileCompiler()); - - /** - * `prepareFetchRequest()` reports what is wrong with a request by quoting it. - * Every case below writes a distinct marker into the part it makes malformed, - * and then asks the same three questions: did anything run, what does the run - * say, and what did the journal keep. The marker must appear in none of them. - */ - const MALFORMED: Array<[string, string, string]> = [ - ["an invalid URL", "gx-url-marker", ``], - ["a non-HTTP scheme", "gx-scheme-marker", ``], - ["an unknown method", "GXMETHODMARKER", ``], - [ - "a malformed timeout", - "gx-timeout-marker", - ``, - ], - [ - "a non-string header value", - "gx-header-marker", - ``, - ], - [ - "one header name written twice", - "gx-twice-marker", - ``, - ], - ]; - - for (const [what, marker, element] of MALFORMED) { - it(`GX25: ${what} is refused as a request, carrying nothing of itself`, function* () { - const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - - const attempt = yield* evaluate(request(`${element}\n`, [pinnedFetch([ADMITTED_REQUEST])])); - - expect(transport.performed).toHaveLength(0); - expect(admittedFragments(attempt.events)).toHaveLength(0); - expect(refusals(attempt.events)).toHaveLength(1); - expect(observations(attempt.events)).toHaveLength(0); - // The run says the class, and the journal keeps the class. - expect(attempt.failure).toContain("did not admit"); - expect(persisted(attempt.events)).toContain('"construct":"request"'); - // And neither of them keeps the request that caused it. - expect(attempt.failure).not.toContain(marker); - expect(persisted(attempt.events)).not.toContain(marker); - }); - } - - /** Every incoherent Workspace basis a host can state, and what is wrong. */ - const INCOHERENT: readonly (readonly [string, Partial])[] = [ - ["roots without a selected root", { workspaceRoots: ROOTS, selectedRoot: undefined }], - ["a selected root without roots", { workspaceRoots: undefined, selectedRoot: ROOTS[0] }], - ["no retained root at all", { workspaceRoots: [], selectedRoot: "" }], - ["an empty retained root", { workspaceRoots: [""], selectedRoot: "" }], - ["one retained root twice", { workspaceRoots: [ROOTS[0], ROOTS[0]], selectedRoot: ROOTS[0] }], - [ - "a selected root it does not retain", - { - workspaceRoots: ROOTS, - selectedRoot: "workspace://never-retained", - }, - ], - ]; - - /** The probe under its admitted identity, counting live invocations. */ - function countingProbe(performed: string[]): GeneratedObservation { - return pinnedComponent( - "Probe", - { - kind: "function", - name: "Probe", - props: { type: "object", properties: {}, additionalProperties: false }, - forms: ["self-closing"], - generated: offers("probe/read", "read"), - // deno-lint-ignore require-yield - *fn(): Operation { - performed.push("probed"); - return "probed"; - }, - }, - "component-answer", - ); - } - - for (const [what, basis] of INCOHERENT) { - it(`GX26b: a host stating ${what} fails before anything is appended`, function* () { - const performed: string[] = []; - const attempt = yield* evaluate({ - ...request("\n", [countingProbe(performed)]), - ...basis, - }); - - // The host's own values, so this is the host's own error — and it happens - // before the candidate is read, so nothing of it is retained. - expect([what, attempt.failure !== undefined]).toEqual([what, true]); - expect([what, admissions(attempt.events).length]).toEqual([what, 0]); - expect([what, performed]).toEqual([what, []]); - }); - } - - it("GX26c: a host stating no Workspace basis at all evaluates", function* () { - // The positive control the six above need, and the ordinary-run shape: a - // host with no Workspace is not a host with a broken one. - const { - workspaceRoots: _roots, - selectedRoot: _selected, - ...withoutBasis - } = request("\n", [probe()]); - - const attempt = yield* evaluate(withoutBasis); - - expect(attempt.failure).toBe(undefined); - expect(attempt.output).toContain("probed"); - }); - - it("GX26: a malformed host ceiling fails as the host's own error, before anything is appended", function* () { - const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - - const attempt = yield* evaluate( - request(`\n`, [pinnedFetch([{ url: "gx-host-ceiling-marker" }])]), - ); - - // The host's own values are not generated text: a host that states a - // request it cannot mean is told which one, and nothing is retained. - expect(transport.performed).toHaveLength(0); - expect(admissions(attempt.events)).toHaveLength(0); - expect(attempt.failure).toContain("gx-host-ceiling-marker"); - }); -}); +/** + * The rows a malformed request used to be refused by are gone with the + * inventory that refused it. + * + * Preflight settled what a fragment may *name*; whether a request is well + * formed at all is the component's own question, asked where the request is + * made — after the admission has already been recorded, which is where the + * exact source is retained on purpose. So there is no pre-admission refusal + * left to keep the candidate's text out of, and nothing here to assert about + * one. What an admitted read may reach is proved against a live bound in + * `shared-operation-permissions` Tier PRM, and ``'s own refusals are + * `fetch-component`'s. + */ -describe("Tier GX — a resumed run is held to the ceilings it was admitted under", () => { +describe("Tier GX — a resumed run is held to the selection it was admitted under", () => { beforeAll(() => useTempFileCompiler()); /** A second definition under the same name, so a substitution is visible. */ @@ -1545,9 +1267,8 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde * offers that spelling under. */ const CURRENT_POLICY: JsonObject = Object.freeze({ - version: 3, + version: 4, allow: ["read"], - workspace: { roots: [...ROOTS], selectedRoot: ROOTS[0] }, allowed: [ { ...COMPOSITION_RECORD }, { @@ -1558,7 +1279,6 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde effect: "read", }, ], - requests: [], }); /** One admission holding that policy, as the event a journal holds. */ @@ -1570,7 +1290,7 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde result: { status: "ok", value: { - version: 3, + version: 4, decision: "admitted", source, named: [{ name: "Probe", identity: PROBE_IDENTITY, form: "self-closing" }], @@ -1797,7 +1517,7 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde description: { type: "generated_xmd", name: "generated:turn-1", input: CURRENT_POLICY }, result: { status: "ok", - value: { version: 3, decision: "refused", construct: "block" }, + value: { version: 4, decision: "refused", construct: "block" }, }, }; const again = yield* evaluate(request("\n", [countedProbe(performed)]), { @@ -1826,7 +1546,7 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde result: { status: "ok", value: { - version: 3, + version: 4, decision: "admitted", source: "\n", named: [{ name: "Probe", identity: PROBE_IDENTITY, form: "self-closing" }], @@ -1949,23 +1669,31 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde "a row whose identity is null", forgedAdmission({ ...CURRENT_POLICY, ...forgedRow({ identity: null }) }), ], - ["a repeated retained root", forgedPolicyWorkspace([ROOTS[0], ROOTS[0]], ROOTS[0])], - ["no retained roots at all", forgedPolicyWorkspace([], "")], - ["a selected root the basis does not hold", forgedPolicyWorkspace(ROOTS, "workspace://never")], + // A marker-3 record is the shape this build does not have: it carried a + // Workspace basis and a request inventory, and both are gone. Nothing + // recovers a current policy from it, so it is refused whole rather than + // read for the members it still shares. [ - "a request carrying a member this build does not know", + "a marker-3 record carrying a Workspace basis and a request inventory", forgedAdmission({ - ...CURRENT_POLICY, - requests: [{ url: URL_ONE, method: "GET", headers: {}, widened: true }], + version: 3, + allow: ["read"], + workspace: { roots: [...ROOTS], selectedRoot: ROOTS[0] }, + allowed: CURRENT_POLICY.allowed, + requests: [], }), ], [ - "a request whose timeout is not a number", + "a current record carrying a Workspace basis beside the selection", forgedAdmission({ ...CURRENT_POLICY, - requests: [{ url: URL_ONE, method: "GET", headers: {}, timeout: "soon" }], + workspace: { roots: [...ROOTS], selectedRoot: ROOTS[0] }, }), ], + [ + "a current record carrying a request inventory beside the selection", + forgedAdmission({ ...CURRENT_POLICY, requests: [] }), + ], [ "a result carrying a member this build does not know", forgedAdmission(CURRENT_POLICY, { widened: true }), @@ -1996,7 +1724,7 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde result: { status: "ok", value: { - version: 3, + version: 4, decision: "admitted", source: "\n", named: [ @@ -2021,7 +1749,7 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde result: { status: "ok", value: { - version: 3, + version: 4, decision: "admitted", source: "\n", named: [{ name: "Probe", identity: "urn:host:probe/v1", form: "self-closing" }], @@ -2032,14 +1760,6 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde ], ]; - /** One admission whose Workspace basis is whatever a case states. */ - function forgedPolicyWorkspace(roots: readonly string[], selectedRoot: string): DurableEvent { - return forgedAdmission({ - ...CURRENT_POLICY, - workspace: { roots: [...roots], selectedRoot }, - }); - } - for (const [what, admission] of HOSTILE) { it(`FE18/GX21y: a retained record with ${what} refuses before any effect`, function* () { const first = yield* evaluate(request("\n", [probe()])); @@ -2094,129 +1814,36 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde expect(admittedFragments(again.events)).toHaveLength(1); }); - it("GX22: the run's own root progression resumes and reaches the generated component", function* () { - const performed: string[] = []; - const first = yield* evaluate(request("\n", [countedProbe(performed)])); - expect(first.failure).toBe(undefined); - expect(performed).toHaveLength(1); - - // One more retained root, and the run stands on it now. The admission's - // basis is still retained, so the continuation proceeds — and performs the - // observation its interrupted history never committed, once more. - const again = yield* evaluate( - { - id: "turn-1", - source: "\n", - workspaceRoots: [...ROOTS, ADVANCED], - selectedRoot: ADVANCED, - observations: [countedProbe(performed)], - }, - { stream: duringPreparation(first.events) }, - ); - - expect(again.failure).toBe(undefined); - expect(again.output).toContain("probed"); - expect(performed).toHaveLength(2); - }); - - it("GX22b: the run may stand on another root it already retained", function* () { + it("GX22: the run's own progress resumes and reaches the generated component", function* () { const performed: string[] = []; const first = yield* evaluate(request("\n", [countedProbe(performed)])); expect(first.failure).toBe(undefined); expect(performed).toHaveLength(1); - const again = yield* evaluate( - { - id: "turn-1", - source: "\n", - workspaceRoots: ROOTS, - selectedRoot: ROOTS[1] ?? "", - observations: [countedProbe(performed)], - }, - { stream: duringPreparation(first.events) }, - ); + // The run has moved on since the admission — a committed mutation retains + // another root and advances the frontier — and the selection is what the + // continuation is held to. Nothing about the Workspace takes part, so the + // continuation proceeds and performs the observation its interrupted + // history never committed, once more. + const again = yield* evaluate(request("\n", [countedProbe(performed)]), { + stream: duringPreparation(first.events), + }); expect(again.failure).toBe(undefined); expect(again.output).toContain("probed"); expect(performed).toHaveLength(2); }); - it("GX22c: a lost admission root refuses before the component runs", function* () { - const performed: string[] = []; - const first = yield* evaluate(request("\n", [countedProbe(performed)])); - expect(performed).toHaveLength(1); - - // The admission retained both roots; this run kept only the selected one. - const again = yield* evaluate( - { - id: "turn-1", - source: "\n", - workspaceRoots: [ROOTS[0] ?? ""], - selectedRoot: ROOTS[0] ?? "", - observations: [countedProbe(performed)], - }, - { stream: duringPreparation(first.events) }, - ); - - expect(again.failure).toContain("admitted under"); - expect(String(again.output ?? "")).not.toContain("probed"); - expect(performed).toHaveLength(1); - }); - - it("GX22d: losing the admission's selected root refuses before the component runs", function* () { - const performed: string[] = []; - const first = yield* evaluate(request("\n", [countedProbe(performed)])); - expect(performed).toHaveLength(1); - - // The run progressed, but the root the admission addressed is gone. - const again = yield* evaluate( - { - id: "turn-1", - source: "\n", - workspaceRoots: [ROOTS[1] ?? "", ADVANCED], - selectedRoot: ROOTS[1] ?? "", - observations: [countedProbe(performed)], - }, - { stream: duringPreparation(first.events) }, - ); - - expect(again.failure).toContain("admitted under"); - expect(String(again.output ?? "")).not.toContain("probed"); - expect(performed).toHaveLength(1); - }); - - it("GX23: a widened Fetch ceiling refuses before API.Fetch", function* () { - const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - const candidate = request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]); - - const history = yield* interrupted(candidate); - expect(transport.performed).toHaveLength(1); - expect(admissions(history)).toHaveLength(1); - expect(observations(history)).toHaveLength(0); - - // The widened ceiling still contains the original request, so a run that - // consulted only the current policy would admit this fragment again. - const again = yield* evaluate( - request(`\n`, [pinnedFetch([ADMITTED_REQUEST, { url: URL_TWO }])]), - { stream: new InMemoryStream(history) }, - ); - - expect(transport.performed).toHaveLength(1); - expect(observations(again.events)).toHaveLength(0); - expect(again.failure).toContain("admitted under"); - }); - - it("GX23b: the unchanged ceiling still resumes and commits the observation", function* () { + it("GX23b: the unchanged selection still resumes and commits the observation", function* () { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - const candidate = request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]); + const candidate = request(`\n`, [pinnedFetch()]); const history = yield* interrupted(candidate); expect(transport.performed).toHaveLength(1); - const again = yield* evaluate( - request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]), - { stream: new InMemoryStream(history) }, - ); + const again = yield* evaluate(request(`\n`, [pinnedFetch()]), { + stream: new InMemoryStream(history), + }); expect(again.failure).toBe(undefined); expect(transport.performed).toHaveLength(2); @@ -2303,9 +1930,7 @@ describe("Tier GX — the secret gate covers what is retained", () => { yield* useProbedScanner(scanned); yield* useTransport(() => ({ status: 200, body: "response-body-marker" })); return yield* evaluate( - request(`\n`, [ - pinnedFetch([{ url: `${URL_ONE}?query=url-marker` }]), - ]), + request(`\n`, [pinnedFetch()]), ); }); @@ -2320,7 +1945,6 @@ describe("Tier GX — the secret gate covers what is retained", () => { // spelling runs under beside the effect table that admitted it. for (const marker of [ "url-marker", - "workspace://primary", "https://executable.md/components/fetch/read", '"effect":"read"', ]) { @@ -2334,9 +1958,7 @@ describe("Tier GX — the secret gate covers what is retained", () => { const attempt = yield* scoped(function* () { return yield* evaluate( - request(`token ${CANARY}\n\n\n`, [ - pinnedFetch([ADMITTED_REQUEST]), - ]), + request(`token ${CANARY}\n\n\n`, [pinnedFetch()]), ); }); @@ -2351,11 +1973,7 @@ describe("Tier GX — the secret gate covers what is retained", () => { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); const element = ``; - const attempt = yield* evaluate( - request(`${element}\n`, [ - pinnedFetch([{ url: URL_ONE, headers: { authorization: CANARY } }]), - ]), - ); + const attempt = yield* evaluate(request(`${element}\n`, [pinnedFetch()])); expect(attempt.failure).toContain("secret detection rejected content"); expect(persisted(attempt.events)).not.toContain(CANARY); @@ -2480,7 +2098,7 @@ describe("Tier WGAC — the pinned read-only File", () => { const attempt = yield* scoped(function* () { yield* useWorkspaceFiles(root); - return yield* evaluate(request(source, [pinnedFileRead(), pinnedFetch([ADMITTED_REQUEST])])); + return yield* evaluate(request(source, [pinnedFileRead(), pinnedFetch()])); }); expect(attempt.failure).toBe(undefined); @@ -3545,9 +3163,7 @@ describe("Tier GS — generated work names its own admission", () => { it("GS1: the effect inside a fragment carries that fragment's id and no path", function* () { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - const attempt = yield* evaluate( - request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]), - ); + const attempt = yield* evaluate(request(`\n`, [pinnedFetch()])); expect(attempt.failure).toBe(undefined); expect(transport.performed).toHaveLength(1); @@ -3564,17 +3180,14 @@ describe("Tier GS — generated work names its own admission", () => { it("GS2: two fragments in one run stamp their own ids on identical places", function* () { const transport = yield* useTransport(() => ({ status: 200, body: "body" })); const second: GeneratedXmdRequest = { - ...request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]), + ...request(`\n`, [pinnedFetch()]), id: "turn-2", }; - const attempt = yield* evaluate( - request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]), - { - *after(): Operation { - yield* evaluateGeneratedXmd(second); - }, + const attempt = yield* evaluate(request(`\n`, [pinnedFetch()]), { + *after(): Operation { + yield* evaluateGeneratedXmd(second); }, - ); + }); expect(attempt.failure).toBe(undefined); expect(transport.performed).toHaveLength(2); diff --git a/packages/core/tests/shared-operation-permissions.test.ts b/packages/core/tests/shared-operation-permissions.test.ts index 76dd08abb..78c39d278 100644 --- a/packages/core/tests/shared-operation-permissions.test.ts +++ b/packages/core/tests/shared-operation-permissions.test.ts @@ -40,15 +40,7 @@ import { collect } from "../src/collect.ts"; import { executeInstalled } from "../host.ts"; import { retainedSource } from "../src/root-source.ts"; import type { ExecutionInstallation } from "../host.ts"; -import { - directoryEntry, - fetchEntry, - fileDeleteEntry, - fileReadEntry, - fileWriteEntry, - globReadEntry, - syntaxReadEntry, -} from "../host.ts"; +import { selecting } from "./support/generated-selection.ts"; import { registerComponents } from "../src/components/registration.ts"; import { content } from "../src/component-api.ts"; import { readFileText, writeFileText } from "../src/files.ts"; @@ -93,20 +85,71 @@ function* useHost(fixture: Fixture): Operation { } /** - * The ordinary ceiling, with the write table admitted. + * The ordinary selection, with the writing spellings offered. * - * The write form is admitted deliberately: a generated write that preflight + * The writing forms are offered deliberately: a generated write that preflight * refused would prove nothing about live permission, because the fragment * would never reach an operation. These rows are about the refusal that * happens at the provider, so the fragment has to be allowed to try. */ function ceiling(): ExecutionInstallation { - return { - evaluation: { - read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], - write: [fileWriteEntry(), directoryEntry("Dir"), fileDeleteEntry()], + return selecting({ + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + Dir: ["paired"], + Glob: ["self-closing"], + Syntax: ["self-closing"], + }); +} + +/** One request a host's live terminal policy admits. */ +interface Bound { + readonly url: string; + readonly method: string; + readonly headers: Record; +} + +/** + * A host that bounds what a request may reach, as the live policy it is. + * + * The selection offers the `` spelling; this decides what an admitted + * read may actually send. `Permissions.fetch` is asked by the `API.Fetch` + * terminal after every contextual transformation and immediately before + * transport, which is what makes these rows about the request that would + * actually go out rather than the one the element first wrote. + * + * The bound is copied here, once, so an in-place edit of an outgoing request + * cannot move it: the comparison reads this host's own values rather than the + * object the chain is still holding. + */ +function boundTo(allowed: readonly Bound[]): Operation { + const bounds = allowed.map((bound) => + Object.freeze({ url: bound.url, method: bound.method, headers: { ...bound.headers } }), + ); + return Permissions.around({ + fetch: ([input, init], next) => { + const method = (init?.method ?? "GET").toUpperCase(); + const headers = { ...init?.headers }; + const permitted = bounds.some( + (bound) => + bound.url === input && + bound.method === method && + Object.keys(bound.headers).length === Object.keys(headers).length && + Object.entries(bound.headers).every(([name, value]) => headers[name] === value), + ); + return permitted ? next(input, init) : false; }, - }; + }); +} + +/** The ordinary selection, plus the network read these rows bound. */ +function reading(): ExecutionInstallation { + return selecting({ + File: ["self-closing"], + Glob: ["self-closing"], + Syntax: ["self-closing"], + Fetch: ["self-closing"], + }); } /** The components these documents are written with. */ @@ -512,6 +555,7 @@ describe("Tier PRM — authored, generated and direct operations under one restr yield* useHost(fixture); yield* useRegions(); const admitted = { url: `${server.origin}/admitted`, method: "GET", headers: {} }; + yield* boundTo([admitted]); const rendered = yield* collect( yield* executeInstalled( { @@ -522,18 +566,7 @@ describe("Tier PRM — authored, generated and direct operations under one restr ), stream: new InMemoryStream(), }, - [ - { - evaluation: { - read: [ - fileReadEntry(), - globReadEntry(), - syntaxReadEntry(), - fetchEntry([admitted]), - ], - }, - }, - ], + [reading()], ), ); // Read inside the scope, before the server is torn down. @@ -554,6 +587,7 @@ describe("Tier PRM — authored, generated and direct operations under one restr yield* useHost(fixture); yield* useRegions(); const admitted = { url: `${server.origin}/admitted`, method: "GET", headers: {} }; + yield* boundTo([admitted]); // A handler composed around the contextual chain while the document runs, // rewriting the destination to somewhere the entry never admitted. yield* API.Fetch.around({ @@ -573,18 +607,7 @@ describe("Tier PRM — authored, generated and direct operations under one restr ), stream: new InMemoryStream(), }, - [ - { - evaluation: { - read: [ - fileReadEntry(), - globReadEntry(), - syntaxReadEntry(), - fetchEntry([admitted]), - ], - }, - }, - ], + [reading()], ), ); } catch (error) { @@ -627,14 +650,11 @@ describe("Tier PRM — authored, generated and direct operations under one restr method: "GET", headers: { "x-bound": "admitted" }, }; + yield* boundTo([admitted]); const source = `\\n\\n'} />\n`; - const admitting: ExecutionInstallation = { - evaluation: { - read: [fileReadEntry(), globReadEntry(), syntaxReadEntry(), fetchEntry([admitted])], - }, - }; + const admitting: ExecutionInstallation = reading(); // The positive control first, in the same scope and against the same // server: the element, the ceiling and the header all agree, and the @@ -708,6 +728,7 @@ describe("Tier PRM — authored, generated and direct operations under one restr method: "GET", headers: { "x-bound": "admitted" }, }; + yield* boundTo([admitted]); const element = `\\n\\n'} />`; @@ -725,18 +746,7 @@ describe("Tier PRM — authored, generated and direct operations under one restr ), stream: new InMemoryStream(), }, - [ - { - evaluation: { - read: [ - fileReadEntry(), - globReadEntry(), - syntaxReadEntry(), - fetchEntry([admitted]), - ], - }, - }, - ], + [reading()], ), ); } catch (error) { @@ -774,6 +784,12 @@ describe("Tier PRM — authored, generated and direct operations under one restr yield* useHost(fixture); yield* useRegions(); const url = `${server.origin}/admitted`; + // Both spellings bounded, so the edit below is inside the policy and the + // row is about the host's own copy rather than about the refusal. + yield* boundTo([ + { url, method: "GET", headers: { "x-bound": "admitted" } }, + { url, method: "GET", headers: { "x-bound": "changed" } }, + ]); const element = `\\n\\n'} />`; @@ -787,24 +803,7 @@ describe("Tier PRM — authored, generated and direct operations under one restr ), stream: new InMemoryStream(), }, - [ - { - evaluation: { - read: [ - fileReadEntry(), - globReadEntry(), - syntaxReadEntry(), - // Both spellings admitted, so the edit below is inside the - // ceiling and the row is about the copy rather than about the - // refusal. - fetchEntry([ - { url, method: "GET", headers: { "x-bound": "admitted" } }, - { url, method: "GET", headers: { "x-bound": "changed" } }, - ]), - ], - }, - }, - ], + [reading()], ), ); accepted = [...server.accepted]; diff --git a/packages/core/tests/support/generated-selection.ts b/packages/core/tests/support/generated-selection.ts new file mode 100644 index 000000000..a8ee905d8 --- /dev/null +++ b/packages/core/tests/support/generated-selection.ts @@ -0,0 +1,38 @@ +/** + * Offering one generated selection to an execution a test drives. + * + * Production hosts offer `Component.generated` from the scope that executes the + * document — a command's run scope, a REPL entry's own scope, an isolated + * child's assembly, a workflow attachment. A test that builds its execution + * with `executeInstalled()` has that scope too, and these helpers are the two + * ways of reaching it: + * + * - `useSelection()` offers it directly, for a case that already owns a scope + * around the execution; and + * - `selecting()` offers it from an installation's `install()`, which canonical + * execution calls in the invocation's own scope *before* it reads the + * selection — so a case can state what a fragment may write beside the rest + * of what it installs, without restating the scope. + * + * Neither is a second delivery path. `install()` is ordinary host setup, and + * what it composes is the one contextual property core reads once. + */ + +import { Component } from "../../src/component-api.ts"; +import type { GeneratedSelection } from "../../src/component-api.ts"; +import type { ExecutionInstallation } from "../../host.ts"; +import type { Operation } from "effection"; + +/** Offer this selection for the current scope. */ +export function useSelection(selection: GeneratedSelection): Operation { + return Component.around({ generated: () => selection }); +} + +/** One installation whose setup offers this selection and nothing else. */ +export function selecting(selection: GeneratedSelection): ExecutionInstallation { + return { + *install(): Operation { + yield* useSelection(selection); + }, + }; +} diff --git a/packages/core/tests/syntax-api.test.ts b/packages/core/tests/syntax-api.test.ts index fbaa54bda..c1d81c5cf 100644 --- a/packages/core/tests/syntax-api.test.ts +++ b/packages/core/tests/syntax-api.test.ts @@ -17,7 +17,8 @@ import { renderSyntaxMarkdown, UnknownComponentError, } from "../mod.ts"; -import { executeInstalled, syntaxReadEntry, useSyntax } from "../host.ts"; +import { executeInstalled, useSyntax } from "../host.ts"; +import { selecting } from "./support/generated-selection.ts"; function symbolsOf(...names: string[]): SyntaxSymbols { return { @@ -62,7 +63,7 @@ function* run(text: string, stream = new InMemoryStream()): Operation { return String( yield* collect( yield* executeInstalled({ ...inlineSource(text), stream, includes: [] }, [ - { evaluation: { read: [syntaxReadEntry()] } }, + selecting({ Syntax: ["self-closing"] }), ]), ), ); diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 1f3846f78..f80ee626d 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -50,11 +50,9 @@ import { Component, content } from "../src/component-api.ts"; import { collect } from "../src/collect.ts"; import { execute } from "../src/execute.ts"; import { executeInstalled, Markdown, sourceDigest } from "../host.ts"; -import type { - ComponentAnswerRequest, - ExecutionInstallation, - FragmentEvaluationInput, -} from "../host.ts"; +import type { ComponentAnswerRequest, ExecutionInstallation } from "../host.ts"; +import type { GeneratedSelection } from "../src/component-api.ts"; +import { useSelection } from "./support/generated-selection.ts"; import { inspectComponent, inspectSyntax } from "../src/inspect.ts"; import { validateDocumentStructure } from "../src/document-validation.ts"; import { registerComponents } from "../src/components/registration.ts"; @@ -1014,9 +1012,9 @@ describe("Tier SYN — the named form", () => { */ const PROBE_LABEL = "probe ran"; - /** The provider-backed entry a layered host admits. */ - function layeredEntry(): FragmentEvaluationInput { - return { read: [{ kind: "component-answer", name: "Open", forms: ["self-closing"] }] }; + /** The provider-backed name a layered host offers. */ + function layeredEntry(): GeneratedSelection { + return { Open: ["self-closing"] }; } /** The registration the inner layer adds beside the bootstrap's own. */ @@ -1065,7 +1063,9 @@ describe("Tier SYN — the named form", () => { yield* useMarkerDocumentation(); yield* registerComponents([layeredComponent("inner"), probeRegistration(probed)]); const local: ExecutionInstallation = { - evaluation: layeredEntry(), + *install(): Operation { + yield* useSelection(layeredEntry()); + }, componentAnswers: [ answerProvider("Open", answer.definition, { origin: "test://layer", @@ -1138,7 +1138,9 @@ describe("Tier SYN — the named form", () => { return yield* run(`\\n'} allow={["read"]} />\n`, [ stating(symbolsOf("Marker")).installation, { - evaluation: layeredEntry(), + *install(): Operation { + yield* useSelection(layeredEntry()); + }, componentAnswers: [ answerProvider("Open", answer.definition, { origin: "test://layer" }), ], @@ -1176,7 +1178,9 @@ describe("Tier SYN — the named form", () => { return yield* run(`\n\n\\n'} allow={["read"]} />\n`, [ stating(symbolsOf("Marker")).installation, { - evaluation: layeredEntry(), + *install(): Operation { + yield* useSelection(layeredEntry()); + }, componentAnswers: [ answerProvider("Open", answer.definition, { origin: "test://layer" }), ], @@ -1220,7 +1224,9 @@ describe("Tier SYN — the named form", () => { [ stating(symbolsOf("Marker")).installation, { - evaluation: layeredEntry(), + *install(): Operation { + yield* useSelection(layeredEntry()); + }, componentAnswers: [ answerProvider("Open", answer.definition, { origin: "test://layer", diff --git a/packages/core/tests/syntax-loaded-copy.test.ts b/packages/core/tests/syntax-loaded-copy.test.ts index e02f50521..4d529ab70 100644 --- a/packages/core/tests/syntax-loaded-copy.test.ts +++ b/packages/core/tests/syntax-loaded-copy.test.ts @@ -45,6 +45,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { ensure, resource, scoped, until } from "effection"; import type { Operation } from "effection"; +import { useSelection } from "./support/generated-selection.ts"; import { rm, writeTextFile } from "@effectionx/fs"; import { exec } from "@effectionx/process"; import { API, Files, useHostFiles } from "@executablemd/runtime"; @@ -345,8 +346,8 @@ describe("Tier SYN — a separately loaded protected implementation", () => { const projected: unknown[] = []; const observed: string[] = []; const profile: ExecutionInstallation = { - evaluation: { - read: [{ kind: "component-answer", name: "Syntax", forms: ["self-closing"] }], + *install(): Operation { + yield* useSelection({ Syntax: ["self-closing"] }); }, componentAnswers: [ { @@ -385,11 +386,11 @@ describe("Tier FE14 — a separately loaded provider publishes its own answer", const loaded = yield* useLoadedProvider(); const stream = new InMemoryStream(); - // The admitted entry states the name and the spelling; what admitting it - // grants comes from the loaded module's own declaration. + // The selection states the name and the spelling; what offering it grants + // comes from the loaded module's own declaration. const profile: ExecutionInstallation = { - evaluation: { - read: [{ kind: "component-answer", name: "Loaded", forms: ["self-closing"] }], + *install(): Operation { + yield* useSelection({ Loaded: ["self-closing"] }); }, componentAnswers: [loaded.installation("test://loaded")], }; diff --git a/packages/git/mod.ts b/packages/git/mod.ts index a04a9409d..88c3390ac 100644 --- a/packages/git/mod.ts +++ b/packages/git/mod.ts @@ -74,7 +74,7 @@ export { gitPlugin } from "./src/plugin.ts"; * Workflow's: a host assembling the generated-evaluation profile asks the * owner for the entry rather than either package writing the identity twice. */ -export { gitDirectoryEntry } from "./src/composition/definitions.ts"; +export { gitDirectorySelection } from "./src/composition/definitions.ts"; /** * Whether *this* Plugin declares anything for a command. * diff --git a/packages/git/src/composition/definitions.ts b/packages/git/src/composition/definitions.ts index 5f280cb3a..d9ff3f247 100644 --- a/packages/git/src/composition/definitions.ts +++ b/packages/git/src/composition/definitions.ts @@ -1,27 +1,26 @@ /** - * This boundary's origin, and the `` entry a workflow run admits. + * This boundary's origin, and the `` spelling a workflow run offers. * * The component is canonical core's - * (`core/src/components/fixed-filesystem.ts`), and so is what admitting it + * (`core/src/components/fixed-filesystem.ts`), and so is what offering it * grants: core's own declaration states the identity and the effect its paired * form runs as (specs/workflow-workspace-spec.md §8.4). What stays here is the - * host's decision to admit the name at all — a workflow run has a `` and + * host's decision to offer the name at all — a workflow run has a `` and * an ordinary run has none. */ -import { directoryEntry } from "@executablemd/core/host"; -import type { FragmentEntry } from "@executablemd/core/host"; +import type { InvocationForm } from "@executablemd/core"; export const COMPOSITION_ORIGIN = "@executablemd/workflow/composition"; /** - * ``'s entry in a workflow run's generated-XMD write table. + * `` in a workflow run's generated selection. * - * A host assembling the generated-evaluation profile asks for this rather than - * writing the admission out again. Core supplies the body behind it and the - * identity it is admitted under, so this names no definition and no grant of - * its own: what it admits is the one `` every authored element reaches. + * A host composing the selection asks for this rather than writing the name and + * the form out again. It is a name and a spelling: core supplies the body + * behind it and the identity it runs under, so this states no definition and no + * grant of its own. */ -export function gitDirectoryEntry(): FragmentEntry { - return directoryEntry("Dir"); +export function gitDirectorySelection(): Readonly> { + return { Dir: ["paired"] }; } diff --git a/packages/workflow/deno.ts b/packages/workflow/deno.ts index f277535ed..1132e0de3 100644 --- a/packages/workflow/deno.ts +++ b/packages/workflow/deno.ts @@ -130,13 +130,18 @@ export { isJournalableWorkspaceFailure, } from "./src/deno/workspace/errors.ts"; /** - * What a host declares to the execution so an authored workflow document has - * ``: its implementation names durable work after its own invocation, - * so canonical execution builds it from the invocation identity function it - * minted. + * The names and authored forms a workflow run offers generated XMD. + * + * `useWorkflowGenerated()` offers them as ordinary `Component.generated` + * middleware, installed in the scope that executes the document. + * `workflowGeneratedSelection()` is the value itself, for a host that composes + * its own middleware around it. */ -export { evaluationProfile } from "./src/deno/workspace/evaluate.ts"; -export type { GeneratedEvaluationOptions } from "./src/deno/workspace/evaluate.ts"; +export { useWorkflowGenerated, workflowGeneratedSelection } from "./src/deno/workspace/evaluate.ts"; +export type { + GeneratedEvaluationOptions, + WorkflowRequestBound, +} from "./src/deno/workspace/evaluate.ts"; export type { WorkflowWorkspaceOptions } from "./src/deno/workspace/published.ts"; export type { WorkflowAgentAttachment, diff --git a/packages/workflow/src/deno/workspace/effect.ts b/packages/workflow/src/deno/workspace/effect.ts index 9494cad37..d80e6eb5e 100644 --- a/packages/workflow/src/deno/workspace/effect.ts +++ b/packages/workflow/src/deno/workspace/effect.ts @@ -17,7 +17,6 @@ import { } from "../../workspace/effect.ts"; import type { WorkflowRunConnections } from "../connections.ts"; import { withEnlistedJournalRoute } from "../journal-route.ts"; -import { currentWorkspaceRoot, retainedWorkspaceRoots } from "./root.ts"; import { savepoint } from "../transaction.ts"; import { isJournaledEffectFailure } from "./errors.ts"; import type { DenoWorkspaceFilesystem } from "./filesystem.ts"; @@ -279,35 +278,6 @@ export function withWorkspaceEffects( }); } -/** - * Which Workspace roots this run has, and which one it is on right now. - * - * Read through the same lease the run's Workspace effects are bound to, so what - * it answers is this run's authoritative storage rather than anything a scope - * could put in front of it. Deliberately the two facts and nothing else: a - * caller that needs a ceiling gets the ceiling, not a connection it could take - * the rest of the run apart with. - * - * Read outside a transaction on purpose. This is a ceiling stated before a - * fragment is admitted, and opening a transaction to state it would nest inside - * the ones the admitted observations go on to open. - */ -export function* workspaceRootSelection( - database: WorkflowRunDatabase, -): Operation<{ roots: string[]; current: string }> { - const selection = yield* WorkspaceEffectProvider.operations.provider; - const connections = selection === undefined ? undefined : workspaceEffectProviders.get(selection); - if (connections === undefined) { - return unavailable(); - } - const lease = connections.validateLease(database); - const { database: storage, path } = lease.connection; - return { - roots: retainedWorkspaceRoots(storage), - current: currentWorkspaceRoot(storage, path), - }; -} - /** * One durable effect performed inside this run's Workspace transaction. * diff --git a/packages/workflow/src/deno/workspace/evaluate.ts b/packages/workflow/src/deno/workspace/evaluate.ts index 1bc52de5d..4fae34781 100644 --- a/packages/workflow/src/deno/workspace/evaluate.ts +++ b/packages/workflow/src/deno/workspace/evaluate.ts @@ -1,127 +1,264 @@ /** - * State the host ceiling for canonical core's Evaluate component + * What a workflow run offers canonical core's Evaluate component * (specs/workflow-workspace-spec.md §8.4). * - * The workflow attachment assembles the profile once; canonical execution captures - * it by value before document code. Props, bindings, Context, middleware and - * generated names cannot widen it. Read includes core's self-closing File, Fetch - * only with exact permitted requests, and captured host additions. Write includes - * paired File, an optional host-supplied directory entry, self-closing File.Delete - * and captured additions, in that order. No directory entry means no directory - * grant. `allow` selects classes, defaulting to read, and never supplies permission. + * The workflow attachment offers one selection of component names and authored + * forms, as ordinary `Component.generated` middleware installed in the scope + * that executes the document. Canonical execution reads the final answer once, + * before public execution policy, resolves each selected name through the + * ordinary import chain, and derives the identity, the effect and the contract + * of every selected spelling from the declaration that owns the implementation. + * Props, bindings, Context, middleware and generated names cannot widen it. * - * The private per-invocation Workspace snapshot reports retained roots and current - * root. Continuation requires admission roots by membership, allowing legitimate - * new roots and frontier advancement without changing the grant. + * The generic selection is core's `` in both spellings and its + * self-closing ``. A host that wants more selects the names and + * forms it registered or its providers answer for; `Dir` and `Fetch` are among + * those, so a generic Workflow host that did not choose either does not gain + * one. `allow` selects classes, defaulting to read, and never supplies + * permission. * - * Entries run ordinary canonical components through contextual API.Files/API.Fetch, - * existing middleware, effective Permissions and the run's effect transaction. - * Deletion remains a workspace_file effect. Relative paths use the run's contextual - * cwd, including Dir/Repository/Worktree placement, never the invocation host cwd. - * This profile supplies admitted names, forms and ceilings rather than - * operations: which identity and effect each admitted spelling runs under comes - * from the declaration that owns its implementation. + * Selected names run ordinary canonical components through contextual + * API.Files/API.Fetch, existing middleware, effective Permissions and the run's + * effect transaction. Deletion remains a workspace_file effect. Relative paths + * use the run's contextual cwd, including Dir/Repository/Worktree placement, + * never the invocation host cwd. + * + * What is deliberately absent is everything live. There is no Workspace + * snapshot input and no request inventory: a destination policy belongs to the + * owner that performs the request, immediately before transport, and the + * effective Fetch timeout is resolved from contextual Config where the request + * is made. */ -import { timeoutFetch } from "@executablemd/runtime"; +import { Component } from "@executablemd/core/api"; +import type { GeneratedSelection } from "@executablemd/core/api"; +import type { InvocationForm } from "@executablemd/core"; +import { Permissions } from "@executablemd/runtime/api"; +import type { FetchInit } from "@executablemd/runtime"; import type { Operation } from "effection"; -import { - fetchEntry, - fileDeleteEntry, - fileReadEntry, - fileWriteEntry, -} from "@executablemd/core/host"; -import type { - FragmentEntry, - FragmentEvaluationInput, - FragmentWorkspaceAccess, - GeneratedRequest, -} from "@executablemd/core/host"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { workspaceRootSelection } from "./effect.ts"; /** - * What a host may configure about generated evaluation. + * What a host may add to a workflow run's generated selection. * * Adapter-private values, supplied before the document runs. Every one of them - * is additive: production may supply none, which is the standard profile above + * is additive: production may supply none, which is the generic selection above * and nothing else. A document prop supplies none of them. */ export interface GeneratedEvaluationOptions { /** - * The exact HTTP reads an admitted fragment may perform. + * Further component names and authored forms this host offers. * - * Empty admits `` not at all — the pinned entry is simply not on the - * allowlist, which is a different thing from admitting it and refusing every - * request. + * Names and spellings, like the generic selection itself. What each selected + * spelling runs as comes from the declaration that owns its implementation, + * so a host adds a name here and never a contract for it. */ - readonly requests?: readonly GeneratedRequest[]; - /** Further read components this host admits beside core's pinned ones. */ - readonly reads?: readonly FragmentEntry[]; - /** Further mutation components this host admits beside the standard profile's. */ - readonly writes?: readonly FragmentEntry[]; + readonly components?: Readonly>; /** - * Optional directory admission, between File write and File.Delete. - * Canonical core owns Dir; the host supplies its admitted entry. Omission grants - * no directory capability. Keep this position: continuation compares the retained - * table entry by entry. + * The exact requests this run may send, for a host that bounds them. + * + * Stating any of them does two separate things, and only the first is about + * the selection: self-closing `` joins the names a fragment may + * write, and a `Permissions` restriction is installed in the scope that + * performs the work. The restriction is the bound — it reads the actual + * destination, method and headers after every `API.Fetch` transformation and + * immediately before transport — so what a fragment or a document is + * admitted to *name* and what a request may *reach* stay two questions. + * + * Stating none bounds nothing and offers no ``: an unbounded network + * read is a decision this host does not make on a run's behalf, and offering + * a name a request could never satisfy would tell an agent it had something + * it does not. */ - readonly directory?: FragmentEntry; + readonly requests?: readonly WorkflowRequestBound[]; } /** - * The run's retained Workspace basis, answered per invocation. + * One request a bounded workflow run may send. * - * A private operation closed over this run's own storage rather than the - * storage itself: the profile canonical execution captures carries the answer - * an invocation needs and no way to reach the database that produced it. + * The destination and the method with it, and the complete set of headers. + * Headers are compared whole rather than as a subset: a bound that admitted a + * request carrying additional headers would admit a credential nobody stated. */ -function workspaceAccess(database: WorkflowRunDatabase): FragmentWorkspaceAccess { - return { - *snapshot() { - const selection = yield* workspaceRootSelection(database); - return { roots: selection.roots, current: selection.current }; - }, - }; +export interface WorkflowRequestBound { + readonly url: string; + /** Defaults to `GET`, compared case-insensitively. */ + readonly method?: string; + /** + * The complete headers. Omitted is none, not "any". + * + * One spelling per name. Two differently cased spellings of one name state no + * single value for it, and this refuses rather than choosing one. + */ + readonly headers?: Readonly>; } /** - * The ceiling a workflow run's generated fragments are admitted under. + * The generic names and forms a workflow run offers. * - * An operation, because the effective Fetch timeout is resolved here — once, - * by the host, at assembly. A ceiling compared across a suspension cannot - * depend on where the comparison happened, so preflight and execution normalize - * against this value rather than reading the context again. + * Core's `` in both spellings and its self-closing ``. A + * run that offers nothing further offers exactly this. */ -export function* evaluationProfile( - database: WorkflowRunDatabase, +export function workflowGeneratedSelection( options: GeneratedEvaluationOptions = {}, -): Operation { - const requests = [...(options.requests ?? [])]; - const timeout = yield* timeoutFetch; +): GeneratedSelection { + return selected(options.components, (options.requests ?? []).length > 0); +} + +/** + * The same names and forms, from values this host has already read. + * + * Separate from the exported function because the installer below must not read + * the host's options a second time: whether `` is offered and what a + * request may reach are two halves of one statement, and two readings of an + * accessor-backed option are two statements. So the installer reads once and + * both halves are derived from what it captured. + */ +function selected( + components: Readonly> | undefined, + bounded: boolean, +): GeneratedSelection { return { - read: [ - fileReadEntry(), - ...(requests.length === 0 ? [] : [fetchEntry(requests)]), - ...(options.reads ?? []), - ], - write: [ - fileWriteEntry(), - // The directory entry is the host's to supply, and a host that supplies - // none grants none. Canonical core owns `` itself, so what a host - // states here is the admission rather than the component: a generic - // Workflow host composing this profile without it is not withholding an - // implementation so much as never having admitted the name. - // - // Its position is the contract. A released journal holds this table - // position by position, so the entry a host does supply occupies the - // slot between the file write and the file delete, exactly where every - // retained continuation expects to find it. - ...(options.directory === undefined ? [] : [options.directory]), - fileDeleteEntry(), - ...(options.writes ?? []), - ], - workspace: workspaceAccess(database), - ...(timeout === undefined ? {} : { fetchTimeout: timeout }), + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + // Offered only where this host bounds what a request may reach. A name + // whose every request the live terminal would refuse is a name a fragment + // could write and never successfully call. + ...(bounded ? { Fetch: ["self-closing"] as const } : {}), + ...(components ?? {}), }; } + +/** + * Offer that selection for this scope. + * + * Installed where the document will execute, which is inside the Workspace + * attachment: a selection offered in a scope that ends before the execution + * would leave a chain core reads nothing from. + * + * It answers rather than delegating. A workflow run's selection is the host + * statement for that run, so an ordinary command's fallback must not supplement + * it — and a run that offers no `Dir` is not withholding an implementation so + * much as never having offered the name. + */ +export function* useWorkflowGenerated(options: GeneratedEvaluationOptions = {}): Operation { + // One reading of each option, and everything below derives from it. Read + // before the selection is offered, because a bound this host cannot state is + // a host statement that is wrong rather than a request that is refused: a + // name offered under it would be a name whose policy nobody settled. + const bounds = captureBounds(options.requests ?? []); + const components = options.components; + // `bounds`, not the options: the name is offered exactly where a request + // policy was installed, so a reading that stated no bound cannot be followed + // by one that offers `` under no policy at all. + const selection = selected(components, bounds.length > 0); + yield* Component.around({ generated: () => selection }); + if (bounds.length === 0) { + return; + } + // The live terminal policy. It delegates rather than answering `true`, so an + // enclosing restriction still refuses what it refused, and it reads the + // request as it would go out — after every `API.Fetch` transformation and + // immediately before transport. + yield* Permissions.around({ + fetch: ([input, init], next) => + bounds.some((bound) => permits(bound, input, init)) ? next(input, init) : false, + }); +} + +/** + * The bounds this host stated, copied once and frozen. + * + * Captured here rather than read at each request. The caller still holds the + * objects it passed, and a policy that read them again would be comparing a + * request against whatever they said by then — so a bound edited in place after + * installation moves nothing. + */ +function captureBounds( + requests: readonly WorkflowRequestBound[], +): readonly Readonly<{ url: string; method: string; headers: ReadonlyMap }>[] { + return Object.freeze( + requests.map((request) => { + const headers = capturedHeaders(request.headers); + if (headers === undefined) { + throw new WorkflowRequestBoundError( + `a bounded workflow request for ${request.url} states ${AMBIGUOUS_HEADERS}`, + ); + } + return Object.freeze({ + url: request.url, + method: normalizedMethod(request.method), + headers, + }); + }), + ); +} + +/** A bound this host cannot state. Raised where it is installed, not later. */ +class WorkflowRequestBoundError extends Error {} + +const AMBIGUOUS_HEADERS = + "two spellings of one header name. A request carries one value per name: a " + + "transport joins differently cased occurrences into a single sent header, so " + + "neither occurrence is what goes out, and a policy that compared one of them " + + "would authorize a value nobody stated."; + +/** + * Whether this bound admits the request that is about to go out. + * + * Destination and method exactly, and the headers whole: the same names and the + * same values, nothing missing and nothing extra. Header names are compared + * case-insensitively because that is what a header name means; values are not. + * A request this cannot read one value per name from is refused rather than + * normalized, so what is compared is always the request as it will be sent. + */ +function permits( + bound: Readonly<{ url: string; method: string; headers: ReadonlyMap }>, + input: string, + init: FetchInit | undefined, +): boolean { + if (bound.url !== input || bound.method !== normalizedMethod(init?.method)) { + return false; + } + const sent = capturedHeaders(init?.headers); + // Ambiguity is refused, never resolved. This bound admits a request whose + // headers it can read exactly, and two spellings of one name are not one + // header anybody stated — so there is nothing here to compare and the + // request is not this bound's. + if (sent === undefined || sent.size !== bound.headers.size) { + return false; + } + for (const [name, value] of bound.headers) { + if (sent.get(name) !== value) { + return false; + } + } + return true; +} + +function normalizedMethod(method: string | undefined): string { + return (method ?? "GET").toUpperCase(); +} + +/** + * The headers by name, or nothing at all when they name one header twice. + * + * Names are read case-insensitively because that is what a header name means. + * What that makes ambiguous is a record carrying two spellings of one name: + * what the transport sends for it is neither entry, so there is no reading of + * such a record that describes the request. Both the host's bound and the + * outgoing request are read through here, so neither side can collapse a + * collision into whichever spelling happened to be last. + */ +function capturedHeaders( + headers: Readonly> | undefined, +): ReadonlyMap | undefined { + const captured = new Map(); + for (const [name, value] of Object.entries(headers ?? {})) { + const named = name.toLowerCase(); + if (captured.has(named)) { + return undefined; + } + captured.set(named, value); + } + return captured; +} diff --git a/packages/workflow/src/generated-observations.ts b/packages/workflow/src/generated-observations.ts deleted file mode 100644 index 2cbe35b09..000000000 --- a/packages/workflow/src/generated-observations.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * The policy a workflow run admits generated XMD under - * (specs/workflow-workspace-spec.md §8.4). - * - * Core owns the mechanics: parsing the candidate fragment, preflighting all of - * it before the first effect, resolving each name and authored form to exactly - * one pinned entry, invoking only those, and recording one durable admission - * before the first generated effect. What core does *not* own is which ceilings - * a workflow run is willing to state, and that is this module. - * - * Four of them are workflow concepts: - * - * - the retained Workspace roots a run has, and which one admitted effects - * address. The list is the run's current immutable retained set and the - * selected root is the authoritative live root, a member of that set; a - * generated fragment cannot supply, add, or select either — so the selection - * is checked here, before the fragment is read at all. On a continuation the - * admission's root basis is held by membership: the run's own progress may - * have retained further roots and advanced the current one, while every - * non-root term is held exactly; - * - the exact HTTP reads a fragment may perform. An empty ceiling admits no - * `` whatsoever: the pinned entry simply is not on the allowlist, - * which is a different thing from admitting it and refusing every request; - * - the read entries this run adds beyond core's own; and - * - the mutation entries this run is willing to expose at all. They are - * stated whether or not a caller asks for them, because what selects them is - * the caller's `allow`, and a class a run has no table for is refused before - * the candidate is parsed. - * - * The policy is a value the trusted host holds and passes. Nothing here reads a - * context, a registration, or the document, so no middleware and no generated - * name can widen what a run agreed to. - */ - -import { evaluateGeneratedXmd, pinnedFetch } from "@executablemd/core/host"; -import type { - GeneratedEffectClass, - GeneratedMutation, - GeneratedObservation, - GeneratedRequest, -} from "@executablemd/core/host"; -import type { SourcePosition } from "@executablemd/core"; -import type { Operation } from "effection"; - -/** A generated fragment this run will not admit under its own ceilings. */ -export class GeneratedEvaluationPolicyError extends Error { - override name = "GeneratedEvaluationPolicyError"; -} - -/** What one workflow run is willing to admit a generated fragment under. */ -export interface GeneratedEvaluationPolicy { - /** The retained Workspace roots this run has. */ - readonly workspaceRoots: readonly string[]; - /** The one retained root admitted effects address. */ - readonly selectedRoot: string; - /** The exact HTTP reads a fragment may perform. Empty admits `` not at all. */ - readonly requests: readonly GeneratedRequest[]; - /** The read entries this run states, beside the bounded `` below. */ - readonly reads: readonly GeneratedObservation[]; - /** The mutation entries this run states. Absent admits `write` not at all. */ - readonly writes?: readonly GeneratedMutation[]; - /** The classes the caller selected. Omitted means `read`, as core's own default does. */ - readonly allow?: readonly GeneratedEffectClass[]; -} - -/** The read entries one policy resolves to, in the order a run states them. */ -function reads(policy: GeneratedEvaluationPolicy): GeneratedObservation[] { - const admitted: GeneratedObservation[] = [...policy.reads]; - if (policy.requests.length > 0) { - admitted.push(pinnedFetch(policy.requests)); - } - return admitted; -} - -/** - * Admit one generated fragment under this run's ceilings and perform what it - * asks for, answering with the text it rendered. - * - * The fragment decides what its caller receives: a component written with `as` - * binds inside the fragment and renders nothing, and one written without `as` - * contributes its ordinary output. Nothing is collected on the fragment's - * behalf, and a mutation's own durable effect remains the account of it. - * - * The root selection is decided before the candidate is read, because a run - * that cannot say which retained root an effect addresses has no ceiling to - * admit anything under — and a refusal there must cost no parse, no effect, - * and no durable record. - */ -export function* evaluateGeneratedFragment( - id: string, - source: string, - policy: GeneratedEvaluationPolicy, - position?: Readonly, -): Operation { - const retained = new Set(policy.workspaceRoots); - if (retained.size !== policy.workspaceRoots.length) { - throw new GeneratedEvaluationPolicyError( - "a generated-evaluation policy named one retained Workspace root twice.", - ); - } - if (!retained.has(policy.selectedRoot)) { - throw new GeneratedEvaluationPolicyError( - "a generated-evaluation policy selected a Workspace root this run did not retain.", - ); - } - return yield* evaluateGeneratedXmd({ - id, - source, - workspaceRoots: [...policy.workspaceRoots], - selectedRoot: policy.selectedRoot, - observations: reads(policy), - ...(policy.writes === undefined ? {} : { mutations: [...policy.writes] }), - ...(policy.allow === undefined ? {} : { allow: [...policy.allow] }), - ...(position === undefined ? {} : { position }), - }); -} diff --git a/packages/workflow/tests/generated-agent-component.test.ts b/packages/workflow/tests/generated-agent-component.test.ts index 3b9c73b97..0fab5c935 100644 --- a/packages/workflow/tests/generated-agent-component.test.ts +++ b/packages/workflow/tests/generated-agent-component.test.ts @@ -14,25 +14,24 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; -import { directoryEntry, elicitWriteEntry, executeInstalled } from "@executablemd/core/host"; -import type { FragmentEntry } from "@executablemd/core/host"; +import { executeInstalled } from "@executablemd/core/host"; import { expect } from "@executablemd/test-support/expect"; import { scoped, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; -import type { ComponentInvocation } from "@executablemd/core"; +import type { ComponentInvocation, InvocationForm } from "@executablemd/core"; import { collect, Component, content, inlineSource, registerComponents } from "@executablemd/core"; import { createContext } from "effection"; import type { Context } from "effection"; import type { Json } from "@executablemd/durable-streams"; import type { ComponentRegistry, RegistryEntry } from "@executablemd/core"; import type { DurableEvent } from "@executablemd/durable-streams"; -import { API, useHostFiles } from "@executablemd/runtime"; +import { API, FETCH_NOT_PERMITTED_MESSAGE, Permissions, useHostFiles } from "@executablemd/runtime"; import { readTextFile } from "@effectionx/fs"; import { fileURLToPath } from "node:url"; import type { FetchInit, RuntimeFetchResponse } from "@executablemd/runtime"; import type { WorkflowRunDatabase } from "../mod.ts"; import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; -import { evaluationProfile } from "../src/deno/workspace/evaluate.ts"; +import { useWorkflowGenerated } from "../src/deno/workspace/evaluate.ts"; import type { GeneratedEvaluationOptions } from "../src/deno/workspace/evaluate.ts"; import { transactWorkspaceRoots } from "../src/deno/workspace/private.ts"; import { createRun, useStorageRoot, withStorage } from "./support/storage.ts"; @@ -63,6 +62,12 @@ function* useTransport(hold?: () => Operation): Operation { yield* API.Fetch.around( { *fetch([url, init]): Operation { + // Asked first, because that is what an implementation performing the + // request does: the question is asked at the terminal, after every + // contextual transformation and immediately before transport. + if (!(yield* Permissions.operations.fetch(url, init))) { + throw new Error(FETCH_NOT_PERMITTED_MESSAGE); + } performed.push({ url, init }); if (hold !== undefined) { yield* hold(); @@ -117,6 +122,15 @@ function runDocument( // while a generated fragment resolved its paths through the evaluator's own // cursor instead of the contextual directory. within?: string, + /** + * What this host registers in the executing scope, for a case whose selection + * names a component of its own. + * + * A host selects the names it answers for, so a row that offers one supplies + * it the way a host does: an ordinary registration, carrying its own + * generated metadata. + */ + registering?: () => Operation, ): Operation { return scoped(function* () { yield* API.Env.around( @@ -147,24 +161,24 @@ function runDocument( { at: "min" }, ); } + // `` is canonical core's. What this run offers is the + // selection, read by canonical execution before any document code + // exists — and offered here, in the scope that executes the document. + if (registering !== undefined) { + yield* registering(); + } + yield* useWorkflowGenerated({ + ...evaluation, + components: { + // Canonical core implements ``; this host chooses the name + // and the spelling. The generic selection offers no directory at + // all, as WGAC16 demonstrates explicitly. + ...HOST_DIRECTORY, + ...(evaluation.components ?? {}), + }, + }); return yield* collect( - yield* executeInstalled( - { ...inlineSource(source), stream: database.journal }, - // `` is canonical core's. What this run states is the - // ceiling, captured by canonical execution before any document - // code exists. - [ - { - evaluation: yield* evaluationProfile(database, { - // Canonical core implements ``; this host supplies its - // admission entry. The profile states no directory entry of - // its own, as WGAC16 demonstrates explicitly. - directory: HOST_DIRECTORY, - ...evaluation, - }), - }, - ], - ), + yield* executeInstalled({ ...inlineSource(source), stream: database.journal }, []), ); }), ); @@ -253,20 +267,6 @@ function policyOf(event: DurableEvent): Record | undefined { return input; } -/** - * The Workspace basis one admission was recorded under. - * - * A version-2 policy carries it as one member, because a host that evaluates - * against no Workspace states none at all. - */ -function basisOf(event: DurableEvent): Record | undefined { - const workspace = policyOf(event)?.workspace; - if (typeof workspace !== "object" || workspace === null || Array.isArray(workspace)) { - return undefined; - } - return workspace; -} - /** * What the run reported, whether it rendered it or failed with it. * @@ -400,7 +400,16 @@ function row( identity: string, effect: "read" | "write" | "composition", ): Record { - return { name, form, identity, kind: "capability", effect }; + // Every selected name is an answer the import chain resolved, which is the + // one way a selected name reaches an implementation. Core's own composition + // keeps its own representation. + return { + name, + form, + identity, + kind: effect === "composition" ? "capability" : "component-answer", + effect, + }; } describe("Tier WGAC — the registered Evaluate component", () => { @@ -442,16 +451,16 @@ describe("Tier WGAC — the registered Evaluate component", () => { }); }); - it("WGAC4: the roots come from the run, and follow it as it moves", function* () { + it("WGAC4: both observations go through the run's own Workspace as it moves", function* () { const root = yield* useStorageRoot(); yield* withStorage(root, function* () { const database = yield* createRun(); yield* plant(database, "notes.md", "the retained note\n"); // Two observations with a Workspace write between them. The write - // publishes a new root, so the second observation is admitted against a + // publishes a new root, so the second observation runs against a // different current root than the first — which is what makes this a test - // of "the root this run is on" rather than "some root this run retains". + // of the run's own frontier rather than of a basis stated once. const attempt = yield* runDocument( database, `'} as="first" />\n\n` + @@ -469,31 +478,24 @@ describe("Tier WGAC — the registered Evaluate component", () => { expect(reported(attempt)).not.toContain('"name"'); expect(reported(attempt)).not.toContain("observations"); + // Two admissions, and neither of them states a Workspace at all. The + // frontier is the run's live state; a grant compared against a copy of it + // would refuse a run for its own progress. const recorded = admissions(attempt.events); expect(recorded).toHaveLength(2); - const first = basisOf(recorded[0]!); - const second = basisOf(recorded[1]!); - - // The run moved between them, and the stated ceiling moved with it. - expect(typeof first?.selectedRoot).toBe("string"); - expect(second?.selectedRoot).not.toBe(first?.selectedRoot); + for (const event of recorded) { + expect(Object.keys(policyOf(event) ?? {}).toSorted()).toEqual([ + "allow", + "allowed", + "version", + ]); + } - // And the last one is the run's authoritative current root, read back - // independently rather than from the record under test. + // And the run did move: the write between them advanced the current root. const current = yield* transactWorkspaceRoots(database, (workspace) => workspace.currentRoot(), ); expect(current.ok).toBe(true); - expect(second?.selectedRoot).toBe(current.ok ? current.value : undefined); - - const retained = Array.isArray(second?.roots) ? second.roots : []; - expect(retained.length).toBeGreaterThan(1); - expect(retained).toContain(second?.selectedRoot); - // Deterministic order, so the record reads the same however SQLite would - // have ordered the rows. A continuation asks for this basis by membership - // rather than positionally, so the order is record stability rather than - // the thing the comparison turns on. - expect(retained).toEqual([...retained].sort()); }); }); @@ -928,13 +930,13 @@ describe("Tier WGAC — the registered Evaluate component", () => { const admitted = yield* runDocument( database, `ask?'} />\n`, - { writes: [elicitWriteEntry()] }, + { components: { Elicit: ["paired"] } }, ); expect(reported(admitted)).not.toContain("did not admit"); }); }); - it("WGAC5: the exact Fetch ceiling is the host's, and an empty one admits no request", function* () { + it("WGAC5: the host offers `` only where it bounds one, and the bound is live", function* () { const root = yield* useStorageRoot(); yield* withStorage(root, function* () { const database = yield* createRun(); @@ -963,31 +965,55 @@ describe("Tier WGAC — the registered Evaluate component", () => { const third = yield* useStorageRoot(); yield* withStorage(third, function* () { const database = yield* createRun(); - // A different request under the same ceiling performs nothing. + // A different destination under the same bound performs nothing. The name + // is offered — this host bounds a request — so the refusal is the live + // terminal's, asked immediately before transport. const outside = yield* runDocument( database, `'} />\n`, { requests: [{ url: URL_ADMITTED }] }, ); - expect(reported(outside)).toContain("did not admit"); + expect(reported(outside)).toContain("not permitted in this scope"); expect(outside.performed).toHaveLength(0); }); }); }); /** - * The directory entry this test host states. + * The directory spelling this test host offers. * - * The identity released builds retained, written out here as this suite's own - * compatibility data — several rows below assert the table a run admits, and - * what they assert is exactly these strings. + * Canonical core implements ``. This host chooses the name and the + * spelling; the identity and the effect the paired form runs under are core's + * own declaration's. The generic workflow selection offers none. + */ +const HOST_DIRECTORY: Readonly> = { Dir: ["paired"] }; + +/** + * One component of this host's own, which its selection may name. * - * Canonical core implements ``. This host states its admission identity; - * the origin and version-1 alias below are retained compatibility strings, - * not the implementation's package. The profile supplies no directory entry - * of its own. + * Registered in the executing scope, with its own declaration stating the + * identity and the effect its self-closing spelling runs under. A host offers + * a name it answers for; what offering it grants is this declaration's. */ -const HOST_DIRECTORY: FragmentEntry = directoryEntry("Dir"); +function nestedComponent(wrote: string[]) { + return { + name: "Nested", + origin: "test://nested", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"] as const, + generated: { + "self-closing": { + identity: "https://executable.md/components/nested/write", + effect: "write" as const, + }, + }, + // deno-lint-ignore require-yield + *fn(): Operation { + wrote.push("nested ran"); + return ""; + }, + }; +} /** * Tier WGAC — the effect classes `` selects between @@ -1111,8 +1137,8 @@ describe("Tier WGAC — the standard write table", () => { // The trusted composition table leads every policy, whatever the // selection asked for. row("Json", "self-closing", "https://executable.md/components/json", "composition"), - row("File", "paired", "https://executable.md/components/file/write", "write"), row("Dir", "paired", "https://executable.md/components/dir/ensure", "write"), + row("File", "paired", "https://executable.md/components/file/write", "write"), row("File.Delete", "self-closing", "https://executable.md/components/file/delete", "write"), ]); }); @@ -1154,50 +1180,55 @@ describe("Tier WGAC — the standard write table", () => { // A host states the component behind ``; this package states the // rest of the ceiling. A host that states none is admitted under no // directory entry at all, which is why every case here supplies one. - const attempt = yield* runDocument(database, evaluates(WRITES, ["write"]), { - directory: directoryEntry("Dir"), - }); + const attempt = yield* runDocument(database, evaluates(WRITES, ["write"])); expect(attempt.failure).toBe(undefined); expect(yield* stored(database, "/nested/out.md")).toBe("the fragment wrote this"); - // Same position in the same table: the captured entry replaces the one - // this package states rather than being appended beside it, so a - // continuation compares one directory grant and not two. + // One row per selected spelling, in the canonical order the selection is + // admitted in: a host's object order is a property of how the selection + // was assembled rather than of what it chose, so the retained rows are + // ordered by name and a continuation compares one directory grant. const policy = policyOf(admissions(attempt.events)[0]!); expect(policy?.allowed).toEqual([ row("Json", "self-closing", "https://executable.md/components/json", "composition"), - row("File", "paired", "https://executable.md/components/file/write", "write"), row("Dir", "paired", "https://executable.md/components/dir/ensure", "write"), + row("File", "paired", "https://executable.md/components/file/write", "write"), row("File.Delete", "self-closing", "https://executable.md/components/file/delete", "write"), ]); }); }); - it("WGAC17: a captured write is admitted beside the standard table, not instead of it", function* () { + it("WGAC17: a host's own name is offered beside the generic selection, not instead of it", function* () { const root = yield* useStorageRoot(); yield* withStorage(root, function* () { const database = yield* createRun(); + const wrote: string[] = []; - // `writes` is additive and stays additive: a host that states one of its - // own keeps core's write and delete and this package's directory entry, - // and its own entry follows them. - const attempt = yield* runDocument(database, evaluates(WRITES, ["write"]), { - writes: [directoryEntry("Nested")], - }); + // `components` is additive and stays additive: a host that offers one of + // its own keeps core's write and delete and this package's directory + // spelling. What it offers is a name *it* answers for — a host selects + // its registered or provider-backed components, and cannot rename core's. + const attempt = yield* runDocument( + database, + evaluates(`${WRITES}\n`, ["write"]), + { components: { Nested: ["self-closing"] } }, + undefined, + undefined, + () => registerComponents([nestedComponent(wrote)]), + ); expect(attempt.failure).toBe(undefined); expect(yield* stored(database, "/nested/out.md")).toBe("the fragment wrote this"); + expect(wrote).toEqual(["nested ran"]); const policy = policyOf(admissions(attempt.events)[0]!); expect(policy?.allowed).toEqual([ row("Json", "self-closing", "https://executable.md/components/json", "composition"), - row("File", "paired", "https://executable.md/components/file/write", "write"), - // The released entry, still in its own position and unmoved by the - // addition beside it. row("Dir", "paired", "https://executable.md/components/dir/ensure", "write"), + row("File", "paired", "https://executable.md/components/file/write", "write"), row("File.Delete", "self-closing", "https://executable.md/components/file/delete", "write"), - row("Nested", "paired", "https://executable.md/components/dir/ensure", "write"), + row("Nested", "self-closing", "https://executable.md/components/nested/write", "write"), ]); }); }); diff --git a/packages/workflow/tests/generated-observations.test.ts b/packages/workflow/tests/generated-observations.test.ts index 17f101106..689f47e02 100644 --- a/packages/workflow/tests/generated-observations.test.ts +++ b/packages/workflow/tests/generated-observations.test.ts @@ -1,51 +1,66 @@ /** - * Tier WGX — the ceilings a workflow run admits generated observations under + * Tier WGX — what a workflow run retains when it admits a generated fragment * (specs/workflow-workspace-spec.md §8.4). * * Core decides what a generated fragment *is* — what parses, what preflights, - * which pinned identity a name runs, and what the run retains. This suite is - * about the half core does not own: which retained Workspace root the run - * selects, and which HTTP reads it is willing to state. + * which implementation a selected name runs, and what the run retains. This + * suite is about the half that is the run's: the selection a workflow host + * offers, what a marker-4 admission holds and does not hold, and that a + * completed effect restores from its record rather than being performed again. * - * Each case counts what the substituted transport was asked to perform and what - * the journal holds, because a policy that refuses correctly and still performs - * a request has refused nothing. + * Every row drives the public `` through a real execution against the + * selection `useWorkflowGenerated()` offers. There is no wrapper between them + * any more: a host contributes names and forms through `Component.generated`, + * and canonical execution reads that answer once before public policy. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped, sleep } from "effection"; +import { ensure, resource, scoped, sleep, useScope, withResolvers } from "effection"; import type { Operation } from "effection"; -import { API } from "@executablemd/runtime"; +import { createServer } from "node:http"; +import { API, FETCH_NOT_PERMITTED_MESSAGE, Permissions } from "@executablemd/runtime"; import type { FetchInit, RuntimeFetchResponse } from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, Json } from "@executablemd/durable-streams"; import { retainedSource, SOURCE_POSITION_FIELD } from "@executablemd/core"; -import type { SourcePosition } from "@executablemd/core"; -import { executeInstalled, pinnedFileRead } from "@executablemd/core/host"; -import type {} from "@executablemd/core/host"; -import type { DurablePreparation, ExecutionInstallation } from "@executablemd/core/host"; -import { evaluateGeneratedFragment } from "../src/generated-observations.ts"; -import type { GeneratedEvaluationPolicy } from "../src/generated-observations.ts"; +import { executeInstalled } from "@executablemd/core/host"; +import { collect } from "@executablemd/core"; +import { + useWorkflowGenerated, + workflowGeneratedSelection, +} from "../src/deno/workspace/evaluate.ts"; +import type { GeneratedEvaluationOptions } from "../src/deno/workspace/evaluate.ts"; +import { recordedFiles, useRecordedFiles } from "../../core/tests/support/fragment-files.ts"; +import type { RecordedFiles } from "../../core/tests/support/fragment-files.ts"; const ROOT_PATH = "workflows/agent.md"; -const ROOT_SOURCE = "The run admitted a generated fragment.\n"; const URL_ONE = "https://api.example.test/one"; const URL_TWO = "https://api.example.test/two"; -const PRIMARY = "workspace://primary"; -const SECONDARY = "workspace://secondary"; +const NOTE = "the retained note\n"; /** What the substituted transport was asked to do. */ interface Transport { readonly performed: Array<{ url: string; init: FetchInit | undefined }>; } +/** + * A transport that stands in for one that performs the request. + * + * It asks `Permissions` as the first thing it does, because that is what the + * implementation performing the operation does: the question is asked at the + * terminal, after every contextual transformation and immediately before + * transport. A fixture that skipped it would make every policy row here pass + * whatever the policy said. + */ function* useTransport(): Operation { const performed: Transport["performed"] = []; yield* API.Fetch.around( { - // deno-lint-ignore require-yield *fetch([url, init]): Operation { + if (!(yield* Permissions.operations.fetch(url, init))) { + throw new Error(FETCH_NOT_PERMITTED_MESSAGE); + } performed.push({ url, init }); return { status: 200, @@ -62,7 +77,7 @@ function* useTransport(): Operation { return { performed }; } -/** What one run under one policy produced. */ +/** What one run under one selection produced. */ interface Attempt { output?: string; failure?: string; @@ -70,256 +85,510 @@ interface Attempt { } /** - * Drive the wrapper from a `DurablePreparation`. + * Run one document under this host's selection. * - * The preparation is a harness choice, not the production path: production - * reaches the wrapper through the workflow host's declared `` - * component inside the owning document expansion. The wrapper is an - * `Operation` whose durable effects identify themselves against the durable - * root they run in, so a preparation drives it unchanged — the `Workflow` - * annotation narrows only the static yield type, which is what the cast - * widens past. + * The selection is offered in the scope the execution runs in, which is where + * the workflow attachment offers it: canonical execution reads that contextual + * answer once, after initialization and the installation callbacks and before + * public execution policy. */ -function driven(work: () => Operation): ExecutionInstallation { - return { prepare: work as DurablePreparation }; +function run( + source: string, + options: { + generated?: GeneratedEvaluationOptions; + events?: DurableEvent[]; + files?: RecordedFiles; + } = {}, +): Operation { + return scoped(function* () { + yield* useWorkflowGenerated(options.generated ?? {}); + return yield* once(source, options); + }); } /** - * Run one fragment under one policy, from the position a trusted host records - * durable work from. + * One execution under whatever this scope already offers. * - * `events` continues a retained journal instead of starting an empty one, and - * `position` stands in for the authored `` element's own source. + * Separate from `run` because a row about what an installed policy decides over + * time needs two executions while that policy is still live: a second `run` + * would install a second policy, and the first one's scope would already have + * ended. */ -function evaluate( +function once( source: string, - policy: GeneratedEvaluationPolicy, - options: { events?: DurableEvent[]; position?: Readonly } = {}, + options: { events?: DurableEvent[]; files?: RecordedFiles } = {}, ): Operation { return scoped(function* () { const stream = new InMemoryStream(options.events ?? []); - const captured: { result?: string } = {}; - const installation = driven(function* () { - captured.result = yield* evaluateGeneratedFragment( - "turn-1", - source, - policy, - options.position, + if (options.files !== undefined) { + yield* useRecordedFiles(options.files); + } + try { + const output = yield* collect( + yield* executeInstalled({ ...retainedSource(ROOT_PATH, source), stream, includes: [] }, []), ); - }); - const execution = yield* executeInstalled( - { ...retainedSource(ROOT_PATH, ROOT_SOURCE), stream, includes: [] }, - [installation], - ); - const result = yield* execution; - const events = yield* stream.readAll(); - if (result.ok) { + return { output: String(output), events: yield* stream.readAll() }; + } catch (error) { return { - output: captured.result ?? "", - events, + failure: error instanceof Error ? error.message : String(error), + events: yield* stream.readAll(), }; } - return { failure: result.error.message, events }; }); } -function admissions(events: DurableEvent[]): DurableEvent[] { +/** What a real server was asked, outside the policy that authorized it. */ +interface Loopback { + readonly origin: string; + /** The bounded header of each request, as the server read it. */ + readonly received: Array; + /** Each request's method and path, for a row that asserts none arrived. */ + readonly asked: string[]; +} + +/** + * A server that answers one path, torn down with the calling scope. + * + * Real, because the question a header bound answers is what the transport + * sends: a substituted transport reads the request object the policy read, and + * two differently cased spellings of one name are one header only once + * something has actually sent them. + */ +function useLoopback(): Operation { + return resource(function* (provide) { + const received: Array = []; + const asked: string[] = []; + const server = createServer((request, response) => { + const header = request.headers["x-bound"]; + received.push(Array.isArray(header) ? header.join(", ") : header); + asked.push(`${request.method} ${request.url}`); + response.writeHead(200, { "content-type": "text/plain" }); + response.end("body"); + }); + + const listening = withResolvers(); + let onError: ((error: Error) => void) | undefined; + yield* ensure(() => { + if (onError) { + server.off("error", onError); + } + }); + onError = (error: Error) => listening.reject(error); + server.on("error", onError); + server.listen(0, "127.0.0.1", () => listening.resolve()); + yield* listening.operation; + + yield* ensure(function* () { + const closed = withResolvers(); + server.closeAllConnections(); + server.close(() => closed.resolve()); + yield* closed.operation; + }); + + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("the loopback server reported no TCP address"); + } + yield* provide({ origin: `http://127.0.0.1:${address.port}`, received, asked }); + }); +} + +/** Whatever this operation refused with, as text. */ +function* raised(operation: Operation): Operation { + try { + yield* operation; + return undefined; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +/** A fragment that fetches one destination with these exact headers. */ +function fetching(url: string, headers: Readonly>): string { + return evaluating( + `\n` + + `\n`, + ); +} + +/** One fragment, as the authored element that evaluates it. */ +function evaluating(fragment: string, allow: readonly string[] = ["read"]): string { + return ( + `\n\n\n` + ); +} + +function admissions(events: readonly DurableEvent[]): DurableEvent[] { return events.filter( (event) => event.type === "yield" && event.description.type === "generated_xmd", ); } -/** Every generated-XMD record that admitted its fragment. */ -function admittedFragments(events: DurableEvent[]): DurableEvent[] { - return admissions(events).filter((event) => { - if (event.type !== "yield" || event.result.status !== "ok") { - return false; - } - const value = event.result.value; - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return false; - } - return value.decision === "admitted"; - }); +/** The policy one admission was recorded under, as the journal holds it. */ +function policyOf(event: DurableEvent | undefined): Record | undefined { + if (event?.type !== "yield") { + return undefined; + } + const input = event.description.input; + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return undefined; + } + return input; } -function observations(events: DurableEvent[]): DurableEvent[] { +function reads(events: readonly DurableEvent[]): DurableEvent[] { return events.filter( - (event) => - event.type === "yield" && event.description.type === "fetch" && event.result.status === "ok", + (event) => event.type === "yield" && event.description.type === "workspace_file", ); } -const ADMITTED: readonly Record[] = [{ url: URL_ONE }]; - -function policy(overrides: Partial = {}): GeneratedEvaluationPolicy { - return { - workspaceRoots: [PRIMARY, SECONDARY], - selectedRoot: PRIMARY, - requests: ADMITTED, - // What the real host always states, so an empty `requests` here is a run - // admitting no `` rather than a run admitting nothing at all. - reads: [pinnedFileRead()], - ...overrides, - }; +function fetches(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter((event) => event.type === "yield" && event.description.type === "fetch"); } -describe("Tier WGX — the roots a run selects", () => { - it("WGX1: the admission records the root the run selected and the ones it retained", function* () { - yield* useTransport(); +/** How each of these effects settled, as the journal holds it. */ +function statuses(events: readonly DurableEvent[]): string[] { + return events.map((event) => event.result.status); +} - const attempt = yield* evaluate(`\n`, policy()); +describe("Tier WGX — the selection a workflow run offers", () => { + it("WGX1: the generic selection is File's two spellings and the deletion", function* () { + // A generic Workflow host offers exactly this. `` and `` are + // names a host chooses, so a run that chose neither has neither — which is + // an absence among offered names rather than a table that happens to be + // empty. + expect(workflowGeneratedSelection()).toEqual({ + File: ["self-closing", "paired"], + "File.Delete": ["self-closing"], + }); + yield* useScope(); + }); + + it("WGX2: a marker-4 admission holds the selection and nothing live", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + + const attempt = yield* run(evaluating(`\n`), { files }); expect(attempt.failure).toBe(undefined); - const [admission] = admissions(attempt.events); - if (admission?.type !== "yield") { - throw new Error("the run recorded no generated-XMD admission"); - } - // The current shape: a workflow host states a Workspace basis, and it - // travels as one member because an ordinary host states none at all. - expect(admission.description.input).toMatchObject({ - version: 3, - workspace: { roots: [PRIMARY, SECONDARY], selectedRoot: PRIMARY }, - }); + expect(attempt.output).toContain("the retained note"); + const policy = policyOf(admissions(attempt.events)[0]); + // The closed shape this build writes: the marker, the selected classes and + // the rows. No Workspace basis and no request inventory — what a run stands + // on and what a request may reach are both live state, and a grant compared + // against a copy of either would refuse a run for its own progress. + expect(Object.keys(policy ?? {}).toSorted()).toEqual(["allow", "allowed", "version"]); + expect(policy?.version).toBe(4); + expect(policy?.allow).toEqual(["read"]); + expect(policy?.allowed).toEqual([ + { + name: "Json", + form: "self-closing", + identity: "https://executable.md/components/json", + kind: "capability", + effect: "composition", + }, + { + name: "File", + form: "self-closing", + identity: "https://executable.md/components/file/read", + kind: "component-answer", + effect: "read", + }, + ]); }); - it("WGX2: a root the run did not retain is refused before the fragment is read", function* () { - const transport = yield* useTransport(); + it("WGX3: a fragment that asks for nothing is admitted and retains its source", function* () { + const attempt = yield* run(evaluating("nothing to observe here.\n")); - const attempt = yield* evaluate( - `\n`, - policy({ selectedRoot: "workspace://never-retained" }), - ); + expect(attempt.failure).toBe(undefined); + expect(attempt.output).toContain("nothing to observe here."); + expect(admissions(attempt.events)).toHaveLength(1); + expect(reads(attempt.events)).toHaveLength(0); + }); - expect(attempt.failure).toContain("did not retain"); - expect(transport.performed).toHaveLength(0); + it("WGX4: a document with no `` makes no admission at all", function* () { + // The untaken half of the previous row: offering a selection is not + // evaluating anything, so a run that never writes the element appends no + // admission and resolves nothing on its behalf. + const attempt = yield* run("The run wrote no fragment.\n"); + + expect(attempt.failure).toBe(undefined); + expect(attempt.output).toContain("The run wrote no fragment."); expect(admissions(attempt.events)).toHaveLength(0); }); - it("WGX3: one root named twice is refused", function* () { - const transport = yield* useTransport(); + it("WGX5: a name this host did not offer is refused before any effect", function* () { + const files = recordedFiles({ "notes.md": NOTE }); - const attempt = yield* evaluate( - `\n`, - policy({ workspaceRoots: [PRIMARY, PRIMARY] }), + // `` is a core default this host did not choose. A generic workflow + // run therefore has no search at all, and the refusal lands before the read + // beside it in the same fragment. + const attempt = yield* run( + evaluating(`\n\n`), + { files }, ); - expect(attempt.failure).toContain("twice"); - expect(transport.performed).toHaveLength(0); - expect(admissions(attempt.events)).toHaveLength(0); + expect(attempt.failure).toContain("did not admit"); + expect(files.performed).toEqual([]); + expect(admissions(attempt.events).length).toBeLessThanOrEqual(1); + expect(reads(attempt.events)).toHaveLength(0); }); }); -describe("Tier WGX — the reads a run is willing to state", () => { - it("WGX4: an admitted destination performs once and retains one observation", function* () { +describe("Tier WGX — what an admitted request may reach is live", () => { + it("WGX6: a host that bounds no request offers `` not at all", function* () { const transport = yield* useTransport(); - const attempt = yield* evaluate(`\n`, policy()); + const attempt = yield* run(evaluating(`\n`)); + + expect(attempt.failure).toContain("did not admit"); + expect(transport.performed).toHaveLength(0); + }); + + it("WGX7: a bounded destination performs once and retains one observation", function* () { + const transport = yield* useTransport(); + + const attempt = yield* run( + evaluating(`\n\n`), + { generated: { requests: [{ url: URL_ONE }] } }, + ); expect(attempt.failure).toBe(undefined); - expect(transport.performed).toEqual([{ url: URL_ONE, init: { method: "GET", headers: {} } }]); - expect(observations(attempt.events)).toHaveLength(1); + expect(transport.performed.map((call) => call.url)).toEqual([URL_ONE]); + expect(fetches(attempt.events)).toHaveLength(1); }); - it("WGX5: a destination outside the ceiling performs no request", function* () { + it("WGX8: a destination outside the bound reaches no transport", function* () { const transport = yield* useTransport(); - const attempt = yield* evaluate(`\n`, policy()); + // The name is offered — this host bounds a request — and the destination is + // not. So the refusal is the live terminal's, asked immediately before + // transport, and nothing is sent. + const attempt = yield* run( + evaluating(`\n\n`), + { generated: { requests: [{ url: URL_ONE }] } }, + ); - expect(attempt.failure).toContain("did not admit"); + expect(attempt.failure).toContain("not permitted in this scope"); expect(transport.performed).toHaveLength(0); - expect(admittedFragments(attempt.events)).toHaveLength(0); }); - it("WGX6: an empty ceiling admits the pinned Fetch identity not at all", function* () { + it("WGX9: a bound this host stated cannot be moved by editing what it was given", function* () { const transport = yield* useTransport(); + const bound = { url: URL_ONE, headers: { "x-bound": "stated" } }; + + yield* scoped(function* () { + // One installed policy for the whole row. The edit below happens while + // this is still the thing deciding requests, which is the only place an + // in-place edit could have moved anything. + yield* useWorkflowGenerated({ requests: [bound] }); + + const stated = yield* once(fetching(URL_ONE, { "x-bound": "stated" })); + expect(stated.failure).toBe(undefined); + expect(transport.performed).toHaveLength(1); + + // The host still holds the object it passed, and edits it here. + bound.url = URL_TWO; + bound.headers["x-bound"] = "changed"; + + // What it stated is still admitted: the policy compares against the copy + // it captured, not against the object it was handed. + const again = yield* once(fetching(URL_ONE, { "x-bound": "stated" })); + expect(again.failure).toBe(undefined); + expect(transport.performed).toHaveLength(2); + + // And what the edit tried to admit was never bounded. Both halves of it + // are refused — the destination and the header value. + const moved = yield* once(fetching(URL_TWO, { "x-bound": "changed" })); + expect(moved.failure).toContain("not permitted in this scope"); + const changed = yield* once(fetching(URL_ONE, { "x-bound": "changed" })); + expect(changed.failure).toContain("not permitted in this scope"); + expect(transport.performed).toHaveLength(2); + }); + }); - const attempt = yield* evaluate(`\n`, policy({ requests: [] })); + it("WGX13: a header a transformation added is compared as the transport will send it", function* () { + // The real terminal and a real server, because this row is about the + // difference between the request a policy read and the request that goes + // out. Nothing substitutes `API.Fetch`: the terminal asks `Permissions` + // itself, after every transformation and immediately before transport. + const server = yield* useLoopback(); + const url = `${server.origin}/bounded`; + + // Positive control: the request the host bounded, unchanged, reaches the + // server carrying exactly the header that was stated. + const allowed = yield* run(fetching(url, { "x-bound": "admitted" }), { + generated: { requests: [{ url, headers: { "x-bound": "admitted" } }] }, + }); + expect(allowed.failure).toBe(undefined); + expect(server.received).toEqual(["admitted"]); + expect(fetches(allowed.events)).toHaveLength(1); + + // The same bound, with middleware that adds a differently cased spelling of + // the bounded name. A transport joins the two into one sent header, so the + // value that would go out is neither the bounded one nor anything this host + // stated — and the request is refused before transport starts. + const transformed = yield* scoped(function* () { + yield* API.Fetch.around({ + *fetch([input, init], next) { + return yield* next(input, { + ...init, + headers: { "X-Bound": "EXTRA", ...(init?.headers ?? {}) }, + }); + }, + }); + return yield* run(fetching(url, { "x-bound": "admitted" }), { + generated: { requests: [{ url, headers: { "x-bound": "admitted" } }] }, + }); + }); - expect(attempt.failure).toContain("did not admit"); - expect(transport.performed).toHaveLength(0); - expect(admittedFragments(attempt.events)).toHaveLength(0); + // Asked first, because it is the whole question: the server was asked once + // in this row, by the control, and the transformed request reached it not + // at all. A comparator that read a collapsed copy of the headers would let + // this one through carrying `EXTRA, admitted`. + expect(server.received).toEqual(["admitted"]); + expect(transformed.failure).toContain("not permitted in this scope"); + // What it retained is the refusal, not an observation: the request was + // refused where it would have been sent, so there is no response here to + // restore from. + expect(statuses(fetches(transformed.events))).toEqual(["err"]); }); - it("WGX7: a fragment that asks for nothing is admitted and retains its source", function* () { + it("WGX14: a bound naming one header twice is refused where it is stated", function* () { const transport = yield* useTransport(); - const attempt = yield* evaluate("nothing to observe here.\n", policy()); + // The host's own statement, read the same way the outgoing request is. Two + // spellings of one name state no value for it, so this is refused where the + // host installs it rather than becoming whichever one it happened to list + // last — and no selection is offered under a bound nobody settled. + const refusal = yield* raised( + scoped(function* () { + yield* useWorkflowGenerated({ + requests: [{ url: URL_ONE, headers: { "X-Bound": "first", "x-bound": "second" } }], + }); + }), + ); - expect(attempt.failure).toBe(undefined); - expect(attempt.output).toContain("nothing to observe here."); + expect(refusal).toContain("two spellings of one header name"); expect(transport.performed).toHaveLength(0); - expect(admissions(attempt.events)).toHaveLength(1); - expect(observations(attempt.events)).toHaveLength(0); }); -}); -describe("Tier WGX — the authored site an admission retains", () => { - const POSITION: Readonly = Object.freeze({ - path: ROOT_PATH, - offset: 42, - line: 7, - column: 3, + it("WGX15: the request allowance is read once, and `` comes from that reading", function* () { + const server = yield* useLoopback(); + let reads = 0; + // An ordinary accessor-backed options object, which is all a host has to be + // for this to matter: the first reading states no bound and a later one + // states one. Whichever this host meant, the name a fragment may write and + // the policy deciding what it may reach have to come from one reading. + const changing: GeneratedEvaluationOptions = { + get requests() { + reads++; + return reads === 1 ? [] : [{ url: `${server.origin}/admitted` }]; + }, + }; + + const attempt = yield* run( + evaluating(`\n\n`), + { generated: changing }, + ); + + // Asked first, because it is the consequence: the destination the fragment + // names is one no reading of this host's list ever admitted, and a + // selection taken from a second reading would send it under no policy at + // all. + expect(server.asked).toEqual([]); + // The retained reading bounded nothing, so `` was never offered: the + // fragment is refused at admission, by name, and retains no effect. + expect(attempt.failure).toContain("did not admit"); + expect(fetches(attempt.events)).toEqual([]); + // One reading for the whole setup. A second one is what would let the + // selection and the policy disagree about the same host statement. + expect(reads).toBe(1); }); - it("WGX8: the admission carries the outer authored source, and its replay stays aligned", function* () { + it("WGX10: an enclosing restriction still refuses what this host bounded", function* () { const transport = yield* useTransport(); - const source = `\n`; - const first = yield* evaluate(source, policy(), { position: POSITION }); - - expect(first.failure).toBe(undefined); - const [admission] = admissions(first.events); - if (admission?.type !== "yield") { - throw new Error("the run recorded no generated-XMD admission"); - } - expect(admission.description.name).toBe("generated:turn-1"); - expect(admission.description[SOURCE_POSITION_FIELD]).toEqual({ - path: ROOT_PATH, - offset: 42, - line: 7, - column: 3, + const attempt = yield* scoped(function* () { + // Narrower than the host, installed outside it. A restriction delegates + // rather than answering `true`, so the enclosing refusal is never asked + // to delegate and the bound cannot widen it. + yield* Permissions.around({ fetch: () => false }); + return yield* run(evaluating(`\n\n`), { + generated: { requests: [{ url: URL_ONE }] }, + }); }); - expect(transport.performed).toHaveLength(1); - // A continuation of the retained history: the admission and the nested - // fetch replay aligned, so nothing is admitted twice and no request is - // performed again. - const retained = first.events.filter( - (event) => !(event.type === "close" && event.coroutineId === "root"), - ); - const continuation = yield* evaluate(source, policy(), { - events: retained, - position: POSITION, - }); + expect(attempt.failure).toContain("not permitted in this scope"); + expect(transport.performed).toHaveLength(0); + }); +}); - expect(continuation.failure).toBe(undefined); - expect(continuation.output).toEqual(first.output); - expect(admissions(continuation.events)).toHaveLength(1); - expect(transport.performed).toHaveLength(1); +describe("Tier WGX — a completed effect restores from its record", () => { + it("WGX11: a completed read restores with a throwing provider and no Workspace scan", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const source = evaluating(`\n`); - // The position is diagnostic data, not identity: a continuation whose - // element moved still replays the same retained admission. - const moved = yield* evaluate(source, policy(), { - events: retained, - position: { path: ROOT_PATH, offset: 99, line: 9, column: 1 }, + const first = yield* run(source, { files }); + expect(first.failure).toBe(undefined); + expect(files.performed).toEqual(["read notes.md"]); + + // The same document over its own journal, with a provider that throws for + // every operation and a `Permissions` that refuses every one. A completed + // local effect restores its recorded result without entering its live I/O + // callback, asking Permissions, or inventorying the filesystem — so this + // run reaches none of the three. + const asked: string[] = []; + const replayed = yield* scoped(function* () { + yield* Permissions.around({ + files: ([operation]) => { + asked.push(String(operation)); + return false; + }, + }); + return yield* run(source, { + events: first.events, + files: { + ...files, + // deno-lint-ignore require-yield + *readTextFile(): Operation { + throw new Error("the live provider was reached"); + }, + }, + }); }); - expect(moved.failure).toBe(undefined); - expect(admissions(moved.events)).toHaveLength(1); - expect(transport.performed).toHaveLength(1); + expect(replayed.failure).toBe(undefined); + expect(replayed.output).toBe(first.output); + // Permissions was never asked, the throwing provider was never entered, and + // the recorder shows the one read the first run performed and no second. + expect(asked).toEqual([]); + expect(files.performed).toEqual(["read notes.md"]); }); - it("WGX9: an admission asked for without a position retains none", function* () { - yield* useTransport(); + it("WGX12: the admission carries the authored site, and an aligned continuation replays", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const source = evaluating(`\n`); - const attempt = yield* evaluate("nothing to observe here.\n", policy()); - - expect(attempt.failure).toBe(undefined); - const [admission] = admissions(attempt.events); + const first = yield* run(source, { files }); + expect(first.failure).toBe(undefined); + const admission = admissions(first.events)[0]; if (admission?.type !== "yield") { throw new Error("the run recorded no generated-XMD admission"); } - expect(admission.description[SOURCE_POSITION_FIELD]).toBeUndefined(); + // Where the authored element was written, as diagnostic journal data beside + // the admission's identity. + expect(admission.description[SOURCE_POSITION_FIELD]).toMatchObject({ path: ROOT_PATH }); + + // A continuation of the retained history: the admission and the nested read + // replay aligned, so nothing is admitted twice and no read is performed + // again. + const again = yield* run(source, { events: first.events, files }); + + expect(again.failure).toBe(undefined); + expect(again.output).toBe(first.output); + expect(admissions(again.events)).toHaveLength(1); + expect(files.performed).toEqual(["read notes.md"]); }); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 11dbea17f..e4cd7dae8 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1948,7 +1948,7 @@ run but are absent from the diagnostic trace. | File | Contents | |---|---| | `src/eval-transform.ts` | `transformBlock()`, `serializeExports()`, `isJson()`, `TransformResult` | -| `src/component-api.ts` | `Component` Api + `ComponentApi` interface and the direct operations (`importComponent`, `applyModifiers`, `applyBoundModifiers`, `raise`, `env`, `evalScope`, `codeBlock`, `persistent`, `content`, `hasBinding`) — §5.5 | +| `src/component-api.ts` | `Component` Api + `ComponentApi` interface and the direct operations (`importComponent`, `applyModifiers`, `applyBoundModifiers`, `raise`, `env`, `evalScope`, `generated`, `codeBlock`, `persistent`, `content`, `hasBinding`) — §5.5 | | `src/eval-context.ts` | `compileBlock()` — delegates to `API.Env.compile` | | `src/data-uri-compiler.ts` | `useDataUriCompiler()` — data: URI compiler middleware for Deno/Bun; owns `STANDARD_IMPORTS` | | `src/temp-file-compiler.ts` | `useTempFileCompiler()` — temp-file compiler middleware for Node/Bun; owns `STANDARD_IMPORTS` | @@ -1962,7 +1962,7 @@ run but are absent from the diagnostic trace. | `src/fetch-response.ts` | `detachHeaders()`, `detachStatus()`, `FetchResponseRecord` — the response detached from the provider's (§6.18) | | `src/fetch-journal.ts` | `persistFetch()` — the `fetch` durable effect (§6.18, §10.1) | | `src/generated-xmd.ts` | `evaluateGeneratedXmd()`, `pinnedFetch()`, `pinnedFileRead()`, `pinnedFileWrite()`, `pinnedFileDelete()`, `pinnedComponent()`, `pinnedMutation()`, `GeneratedEffectClass`, `GeneratedComponentForm`, `GeneratedImplementation`, `GeneratedMutation`, `GeneratedObservation`, `GeneratedRequest`, `GeneratedXmdError` — admitting Agent-generated source through the trusted-host seam under the always-on trusted composition table and a caller-selected subset of the closed effect classes `read` and `write`, resolving each name and authored spelling to the identity its owning declaration offers that spelling under — core Json, the self-closing `` read, the paired `` write and the self-closing `` among them — and preserving every component's ordinary binding and output behavior. `evaluateGeneratedXmd()` returns `Operation` containing the fragment's rendered text. Its admission and every generated effect use occurrence-owned identities in the existing ordinary durable sequence, with no `ephemeral()` bridge, staging or result envelope | -| `src/glob-source.ts` | `GLOB_PROPS`, `GLOB_RETURNS`, `globPatterns()`, `globFailure()` — the props, return contract, source rules and sanitized failure sentence one search is held to, shared by the ordinary `` component and the Glob capability an evaluation profile admits, so one dialect answers both (§6.14) | +| `src/glob-source.ts` | `GLOB_PROPS`, `GLOB_RETURNS`, `globPatterns()`, `globFailure()` — the props, return contract, source rules and sanitized failure sentence one search is held to, shared by the ordinary `` component and the generated `` a selection offers, which is that same component (§6.14) | | `src/components/component-resolution.ts` | `CanonicalImports`, `ComponentResolution` — the witness a closed execution issues for the definition it produced and verifies where it is invoked, and the per-invocation request a provider publishes its definition through | | `src/components/generated-metadata.ts` | `GeneratedForm`, `GeneratedForms`, `admitGeneratedForms()`, `generatedRefusal()`, `definitionGeneratedRefusal()`, `offeredForm()` — what a declaration says about the generated use of each of its authored spellings: the absolute `https://executable.md/` identity a continuation is compared against, and whether that spelling observes or mutates. One pass reads each member once, validates it and answers with the copy a caller retains, and every live admission reads a definition through it (§5.3) | | `src/invocation-forms.ts` | `formsRefusal()`, `acceptedForms()` — the canonical authored-forms declaration both a registration and its generated metadata are held to (§5.3) | @@ -4322,14 +4322,24 @@ terms `` is owned: no registration, repository file, bundle member, declared Markdown component, import-handler answer or second loaded copy replaces it, and claiming the name is refused where the claim is made. -**Protection settles which implementation runs, and grants nothing.** Everything -the component can reach was stated by a trusted host at the installation -boundary, as one `ExecutionInstallation.evaluation` profile captured by value -before any installation runs. An execution accepts one profile and refuses two — -even two identical ones, because a host that stated it twice has an assembly -nobody validated. An execution offered none has no evaluation at all, and says -so when the element is written rather than at startup: a document that never -writes `` is not asking for a ceiling. +**Protection settles which implementation runs, and grants nothing.** What the +component can reach was offered by a trusted host as one `Component.generated` +selection: a map from component name to the authored forms that name is offered +in, read as one contextual answer before public `Execution.execute` policy. The +default is `undefined`, which is a host that offers no generated evaluation at +all — and says so when the element is written rather than at startup, because a +document that never writes `` is not asking for one. Normal contextual +composition answers the property, so two handlers compose rather than conflict: +a host assembly is responsible for composing the value it intends to offer, and +there is no contributor count to refuse on. + +A defined selection is held to all of it. It names at least one component; every +name is a component name; every value is a canonical form declaration — +`["self-closing"]`, `["paired"]` or `["self-closing", "paired"]`. A malformed +map, name or form list refuses setup, and nothing is repaired, filtered, merged +or replaced with a default: no member of an invalid selection becomes an +executable partial allowance, and no public execution policy, root import, +paired producer, fragment body or generated admission runs after the refusal. **Two disjoint input forms.** @@ -4384,75 +4394,89 @@ profile inputs and results only. Replay reconstructs callable routing from the current execution's verified answers, never from a retained function or route. **`allow` narrows; it never grants.** It names an effect *class* — `read` or -`write` — and the class resolves to a table the host already installed. Omitting -it asks for `read`. A class the host installed nothing for is refused before the -program is read. The admitted vocabulary is the union of the profile's -always-on trusted composition table and the selected effect tables. The ordinary -profile places core's exact Json definition in composition. A composition entry +`write` — and the class resolves to the selected spellings whose owners offer +them as that effect. Omitting it asks for `read`. A class the selection reaches +no spelling of is refused before the program is read. The admitted vocabulary is +the union of the always-on trusted composition table and the selected effect +classes. Core's exact Json definition is in composition. A composition entry contains no effect operation and is not selected through `allow`. Bindings and structural constructs are engine-owned language syntax and need no -profile entry. Another pure component is available only when the trusted host -captured its exact definition and stable identity in the composition table -before execution; purity is never inferred from a name, documentation or the -absence of an effect entry. Conflicting names or forms across composition and -effect tables refuse at profile capture. Generated Syntax describes the same -union preflight and execution use, and continuation reconciles every admitted -composition identity. - -**Entries name components, not operations.** A host states which component an -admitted name runs, and canonical capture answers with canonical core's own -ordinary definition for it — the same ``, ``, ``, -``, `` or `` an author writes. The host supplies no -operation of its own: an admitted element performs its work through contextual -`API.Files` and `API.Fetch` exactly as an authored element does, so a generated -read crosses the middleware an authored read crosses, reaches the provider the -host installed — the caller's own filesystem under `xmd run`, a workflow run's -transaction-bound one under `xmd workflow` — and is decided by the effective -`Permissions` of the scope it runs in. A directory an admitted fragment creates -scopes its content through the ordinary invocation-owned working directory, and -the invocation that installed it takes it away again. - -What a host decides is admission and nothing about performance: which names a -fragment may write, which spelling each name is admitted for, which requests an -admitted `` may perform, and how long any of it lasts. An admitted -implementation is held to the execution that captured it and refuses once that -execution has ended. - -A capability states what it binds when the ordinary component does, because it -*is* the ordinary component: the definition carries that return contract, so -`as` is mandatory, the element renders nothing, and the bound value is validated -— decided by the ordinary value-component rule, before the operation behind the -entry runs. One name runs one component, so a name admitted for capabilities -owned by two different components is refused at capture. - -**The ordinary read table is File, Glob and canonical Syntax.** `xmd run` and -every `host="run"` child state one profile: `read` holds the self-closing -``, the self-closing `` and canonical ``, and `write` -holds the paired `…`, the self-closing `` and paired -canonical ``. A `read` selection therefore never reaches a write form, -and `` remains absent rather than admitted with an empty ceiling. - -`` is in the write table because asking is not reading. A fragment that -may change something may also ask the person first — which is what makes -"preview this, then confirm it" a program an Agent can write — while a `read` -selection promises nobody will be interrupted. It is admitted in its paired -spelling only, under the identity core's own declaration offers that spelling as -a write: the question is the content, so a self-closing one would be a request -with nothing in it. - -It is a *pinned capability*, not a name the fragment resolves. Preflight selects -it where it finds an `` occurrence under a `write` selection, and the body -it selects is canonical core's own `` — closed over before any document -code runs, exactly as the body behind `` is. No component name is resolved -for it at any point: nothing is looked up before the root import, nothing is -looked up at invocation, and middleware answering a generated import with a body -of its own is refused, because only canonical execution answers one. A same-name -repository, registered, declared, middleware or separately loaded `Elicit` -therefore receives no generated permission and never runs — including the -workflow host's own suspension replacement, which continues to answer *authored* -`` in a run exactly as it did. An execution whose fragment never writes -the element resolves nothing and is unaffected by any of them. +selected name. Another pure component is available only when canonical core +holds its exact definition and stable identity in the composition table; +purity is never inferred from a name, documentation or the absence of an effect. +A selection naming a composition name refuses at capture. Generated Syntax +describes the same union preflight and execution use, and continuation +reconciles every admitted composition identity. + +**A selection names components, not operations.** Canonical execution resolves +each selected name once — through the complete ordinary +`Component.importComponent` chain and the canonical terminal behind it, after +initialization and the captured installation callbacks and before public +execution policy — and retains the implementation it answered with together with +that declaration's metadata. Core's own defaults, its fixed filesystem +components, the protected tier, a registration a package made and a provider's +published answer are each a legitimate answer for a selected name; a replacement +nobody published is refused, as is an answer with no implementation to invoke. +Selecting ``, `` or `` therefore resolves the *actual* host +answer, and a replacement whose declaration offers no generated use of the +selected spelling refuses setup rather than falling back to core's default. +`File`, `File.Delete` and `Dir` are canonically protected, so nothing can answer +for them but core. + +What a selected name runs as is the owning declaration's: the absolute +`https://executable.md/` identity each authored form is offered under, and +whether that form observes or mutates. A host offers a name and a spelling and +nothing else — no schema, description, identity, dispatcher or operation — so a +selection cannot describe a contract or an effect the implementation does not +have. An admitted element performs its work through contextual `API.Files` and +`API.Fetch` exactly as an authored element does, so a generated read crosses the +middleware an authored read crosses, reaches the provider the host installed — +the caller's own filesystem under `xmd run`, a workflow run's transaction-bound +one under `xmd workflow` — and is decided by the effective `Permissions` of the +scope it runs in. A directory an admitted fragment creates scopes its content +through the ordinary invocation-owned working directory, and the invocation that +installed it takes it away again. + +Which destination an admitted `` may reach is a **live** question +rather than part of the selection. A host that bounds its requests states that +bound as a `Permissions` restriction in the owner that performs the work, where +it is asked of the transformed request immediately before transport. So a +dynamic destination the fragment's own data expressions produce is an ordinary +input to preflight, and a forbidden one reaches zero transport calls. + +A selected name states what it binds because it *is* the ordinary component: the +definition carries that return contract, so `as` is mandatory, the element +renders nothing, and the bound value is validated — decided by the ordinary +value-component rule, before the operation runs. One name runs one +implementation, however many spellings are selected for it. + +**The ordinary selection is File, File.Delete, Glob, Syntax and Elicit.** +`xmd run` and every `host="run"` child offer the same map: `File` in both +spellings, self-closing `File.Delete`, self-closing `Glob`, self-closing +`Syntax` and paired `Elicit`. Which class each of those reaches is the owner's +answer, so a `read` selection reaches the reading spellings alone. `Dir` and +`Fetch` are absent rather than offered with nothing they could do. + +`` reaches the `write` class because asking is not reading: core's own +declaration offers its paired form as a write, and the selection names the +spelling without deciding that. A fragment that may change something may also +ask the person first — which is what makes "preview this, then confirm it" a +program an Agent can write — while a `read` selection promises nobody will be +interrupted. The paired spelling only: the question is the content, so a +self-closing one would be a request with nothing in it. + +`Elicit` is a replaceable name, so selecting it resolves what this host actually +answers for it — once, before public execution policy, whether or not the +fragment goes on to write the element. A registration, repository file, +declaration or separately loaded definition that legitimately shadows `Elicit` +is therefore what a selected `` would run, and one whose declaration +offers no generated use of the paired form refuses setup rather than silently +reaching core's default. Middleware answering that import with a body nobody +published is refused, because only an implementation canonical resolution +produced or a provider published is one an execution may retain. A host that +replaces `Elicit` for *authored* use and offers it to generated XMD therefore +states the metadata its replacement runs under, or offers the name not at all. What stays contextual is the interaction and only the interaction. The pinned body reaches the Elicitation Api lexically in scope where the fragment runs, so @@ -4472,22 +4496,18 @@ normalized to a sentence naming no path. It performs that search through context element does, and the provider still owns matching, ordering, deduplication, POSIX-relative results and traversal. -`` is admitted as a component answer, never as a capability: a -protected name is one canonical core already owns, and a second body for it -would be a second answer to what the name means. Canonical capture resolves it -once through the ordinary import chain before any document code, where canonical -core publishes the exact answer canonical resolution produced, and seals it -through the execution's own protected-body route. The identity it is admitted -under is the one core's own declaration offers its self-closing spelling as. -Middleware may observe, delegate or refuse that import; an answer it replaces is -one nobody published and is refused. Admitting it grants no other permission — -named documentation may describe a component the selection does not admit, and +`` is a selected name like any other, resolved once through the +ordinary import chain before any document code — where canonical core publishes +the exact answer canonical resolution produced — and captured behind the +execution's own protected-body route. The identity it is admitted under is the +one core's own declaration offers its self-closing spelling as. Middleware may +observe, delegate or refuse that import; an answer it replaces is one nobody +published and is refused. Offering it grants no other permission — named +documentation may describe a component the selection does not admit, and describing one is not permission to run it. -Canonical core publishes an answer for `` and no other protected name. -That publication is what makes a protected name admissible, so an entry -hand-written for `` resolves to an answer nobody published and is -refused: being shown what a fragment may write is a different grant from +`` offers no generated form at all, so a selection naming it refuses +at capture: being shown what a fragment may write is a different grant from evaluation inside evaluation. **What it renders** is the generated fragment's ordinary output. Evaluate does @@ -5398,6 +5418,7 @@ interface, and each operation is also exported directly: | `evalScope` | The current eval scope (§4.4) | `undefined` | | `codeBlock()` | The code block executing through the modifier chain (§3.3) | throws a missing-provider error | | `persistent` | Whether the current block runs with persistent lifetime (§4.4) | `false` | +| `generated` | The component forms this host offers generated XMD, by name (§5.3.2). A plain value property: a consumer yields it without parentheses, and middleware composes one complete selection rather than contributing to a collection | `undefined` — the host offers no generated evaluation at all | | `content(slot?)` | Render the invoking component's content, or a named slot of it; throws `ContentError` when that content fails (§5.1.2, §6.3) | throws a missing-provider error | | `hasContent()` | Whether the invoking element was written with content rather than self-closed | throws a missing-provider error | | `hasBinding()` | Whether the invocation has an engine-owned result binding — whether `as` was written (§6.10) | throws a missing-provider error | @@ -8980,8 +9001,8 @@ and Deno and `EFAULT` under Bun, and the pinned Workspace filesystem removes one outright. Classifying the target before removing it is what makes those differences invisible. -Generated XMD receives `` through the standard Deno workflow -profile's write table (workflow-workspace-spec §8.4). This component's own +Generated XMD receives `` through the standard Deno workflow run's +generated selection (workflow-workspace-spec §8.4). This component's own declaration says what admitting it grants: its self-closing spelling is offered as a write, under `https://executable.md/components/file/delete`, and that is the identity a continuation is compared against. A write-enabled fragment @@ -9792,15 +9813,17 @@ Effection scope. Cancellation aborts them and teardown completes before the invocation closes; no detached promise, task, timer, response body or service survives it. -Generated XMD may use `` only where the trusted host admitted core's own -pinned identity in its `read` table together with the exact requests it may -perform -(workflow-workspace-spec §8.4). A candidate element is normalized by these same -rules and must equal one admitted request — scheme, host, path, method, -normalized headers and effective timeout alike — or it is refused before -`API.Fetch` is reached, with no request performed. Nothing in the authored -component grants that admission, and a repository component that takes the name -`Fetch` is not the pinned identity. +Generated XMD may use `` only where the trusted host selected the name +(workflow-workspace-spec §8.4). What an admitted read may actually reach is a +separate, **live** question: a host that bounds its requests states that bound +as a `Permissions` restriction in the owner that performs the work, where it is +asked of the request as it would go out — after every `API.Fetch` +transformation and immediately before transport. So a destination, method or +header outside the bound reaches no transport at all, while a dynamic +destination the fragment's own data expressions produce is an ordinary input +rather than something preflight has to resolve. Selecting the name resolves +whatever this host answers for `Fetch`, and a replacement whose declaration +offers no generated use of the self-closing form refuses setup. ### 6.19 Showing text as a code block: `` @@ -12378,16 +12401,16 @@ through the captured capability because there is no other way to reach it. | FE23 | An evaluation lacking write permission refuses the write before effects, including when the write component appears in trusted symbols. **Receives the Evaluate clause of #758's SY21**; the half that stays in #759 is that symbols text alone registers, resolves and authorizes nothing. | | FE24 | Repository files, workflow bundle members, declared Markdown, ordinary and reserved registrations, middleware answers or mutations, document context, and another loaded package copy each fail to replace ``; honest middleware delegation reaches canonical core and a deliberate middleware refusal remains a refusal. | | FE25 | A replacement whose body deliberately ignores `allow={["read"]}` and writes through wider ambient permission is never invoked; canonical `` refuses the write before any provider call or mutation. | -| FE26 | Protection itself grants nothing: a profile with no write table stays unable to write, and adding `` to the protected table does not add a class or component identity to `allow`. | +| FE26 | Protection itself grants nothing: a selection whose spellings all read stays unable to write, and selecting `` refuses at capture because its declaration offers no generated form. | | FE27 | Structural constructs remain selected by structural dispatch, while repository replacements for ordinary ``, ``, and `` still win at ordinary authored sites. Their replaceability does not let them enter a generated fragment unless the trusted profile admitted that exact identity and form. | | FE28 | Source, npm, and compiled symbols report `` with protected origin and the approved description, and no host bootstrap is needed to make the name available. | -| FE29 | Each execution accepts one private fragment-evaluation profile containing exact composition, read and write tables; a missing or duplicate profile and conflicts across its tables refuse before paired-content production or fragment effects, and document-controlled state cannot read or widen it — it cannot add a name to the admitted vocabulary, change the identity a continuation is compared against, or give a selection a class the host installed no table for. What a document *can* do is answer the operation, because the provider is deliberately shared: a handler installed anywhere in the document's own scope is what an admitted element reaches, exactly as an authored one does. | +| FE29 | Each execution reads one `Component.generated` selection, once, after initialization and the captured installation callbacks and before public execution policy; an absent selection refuses before paired-content production, a malformed one refuses setup whole, and a later policy cannot widen what was captured — it cannot add a name to the admitted vocabulary, change the identity a continuation is compared against, or give a selection a class no selected spelling reaches. Two handlers compose rather than conflicting. What a document *can* do is answer the operation, because the provider is deliberately shared: a handler installed anywhere in the document's own scope is what an admitted element reaches, exactly as an authored one does. | | FE30 | A trusted nested-run or evaluation-host layering control enters the same declarative package bootstrap through inherited and local layers. The child keeps the package registration and renders the same named documentation as the single-bootstrap control; Evaluate still admits only the identity selected by `allow`. A non-identical owner/component overlap refuses before child root or fragment effects. This receives #765's SYN25l.5 rather than creating a second collector in Evaluate. | | FE31 | One fragment binds File and protected Syntax results locally and explicitly renders a chosen object through Json. Nested literal arrays and objects, binding shorthand, finite JSON numbers and a directly attached leading minus such as `` work in scalar, array and object positions. `1e999`, `-1e999`, `+1`, `-note`, `!note`, `typeof note`, an unbound identifier, call, operator, spread, computed property, template or global reference is refused before any earlier effect. | | FE32 | Generated admission and effect events follow ordinary persist-before-resume publication. A later refusal, failure, cancellation or interruption retains completed effects, and continuation resumes at the occurrence's first unrecorded effect; Evaluate adds no staging or rollback. | | FE33 | Each Evaluate occurrence retains its admission and generated effects under identities belonging to that occurrence; two occurrences cannot consume one another's retained work, and no caller-controlled context, stream, replay cursor, coroutine identifier or durable owner participates. A projected body installs observable structured cleanup, and later parent work proves that cleanup completed before it began. No distinct child cursor, child `Close`, staging, rollback, provisional publication or second settlement protocol is required. | | FE35 | A fragment composes admitted reads with ``, ``, ``/`` and ``/`` under ordinary rules, performing exactly the reads the taken paths name. A prohibited component in an untaken `` arm or an unreached `` refuses the whole fragment with zero reads, and the refusal names the selected permission rather than the branch. An `` item binding does not escape its body while `` does, and a binding the body itself makes cannot be read after the construct. Neither arm of an `` nor either `` of a `` can read a binding another alternative produces; each such refusal performs no read. A `` holding a valid `` and a `` region around an admitted read completes, proving those constructs reach their own handlers rather than a generic structural refusal. A prohibited component inside a paired ``'s template children, and one in the ordinary `` body, are each refused before any effect. An untaken self-closing `` before a selected paired `` write does not surrender its admission to that write: the write performs, the read does not, and the untaken file is unchanged. A two-item `` around one `` performs the read twice with no import refusal, an empty iteration consumes no admission, and a continuation cut between the two repeated effects restores the completed one and resumes at the second. An executable fence and an interpolated caller binding stay refused inside a construct. An ill-formed construct, one the generated root gives no context for, a stray branch and a `` outside every `` are refused as structural mistakes rather than as withheld components. | -| FE34 | One fragment binds Glob, File and canonical Syntax and renders chosen findings through Json; an unmatched search binds `[]`. A search written without `as`, a paired search, and a paired `` under a `read` selection each refuse before any operation. An unusable pattern refuses with the ordinary sentence before the search, and a provider failure becomes one safe sentence naming no path. The search reaches the installed `API.Files` provider and the contextual working directory, so one middleware around the document sees the fragment's request and a transformation it makes is what the provider performs. Bare generated Syntax reports the composition table plus the selected read vocabulary and not the unselected write table; named Syntax describes a component that stays unavailable. Canonical Syntax resolves at core's own identity, and a middleware replacement of that answer refuses. Core answers for that one protected name, so an entry hand-written at ``'s canonical identity is refused as an answer nothing identified. `xmd run` and a `host="run"` child of `xmd test` state the same profile. | +| FE34 | One fragment binds Glob, File and canonical Syntax and renders chosen findings through Json; an unmatched search binds `[]`. A search written without `as`, a paired search, and a paired `` under a `read` selection each refuse before any operation. An unusable pattern refuses with the ordinary sentence before the search, and a provider failure becomes one safe sentence naming no path. The search reaches the installed `API.Files` provider and the contextual working directory, so one middleware around the document sees the fragment's request and a transformation it makes is what the provider performs. Bare generated Syntax reports the composition table plus the selected read vocabulary and not the unselected writing spellings; named Syntax describes a component that stays unavailable. Canonical Syntax resolves at core's own identity, and a middleware replacement of that answer refuses. Selecting `` refuses at capture, because its declaration offers no generated form. `xmd run` and a `host="run"` child of `xmd test` offer the same selection. | Each refusal case needs a negative control proving no producer, middleware answer, request, file mutation, or other program effect occurred. The @@ -13134,14 +13157,14 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §8.4. | # | Test | Verify | |---|------|--------| -| GXC1 | The selection | The trusted composition table is present under every selection. Omitting `allow` and stating `read` produce one identical retained policy; a mixed selection is retained in canonical class order, with composition before the read and write tables and host order inside each | +| GXC1 | The selection | The trusted composition table is present under every selection. Omitting `allow` and stating `read` produce one identical retained policy; a mixed selection is retained in canonical class order, with composition before the read and write classes and the selected names in canonical order inside each | | GXC2 | Unstateable policy | An empty selection, one class twice, a selected class with no table or an empty one, and composition/effect tables holding one name with overlapping forms or distinct definitions each fail before the candidate is parsed — with a deliberately unparseable candidate, no `generated_xmd` record and nothing of the candidate retained | | GXC3–GXC4 | Name and form | A self-closing and a paired `` in one fragment resolve to the read and the write identity and are retained with their forms; the opposite form under a single-class selection is refused with no read and no write | | GXC5–GXC6 | Admission | No admitted name — `` in either form, the versioned paired `` or the self-closing `` — is answered by a same-name repository component, the dotted one included; and Git push, pull request, issue, repository, glob and an executable block are outside the tables whatever the selection | | GXC7 | Ordinary composition | A write-only fragment renders nothing unless it authors output; a mixed fragment binds a read locally and renders exactly the value it explicitly selects | | GXC8 | Preflight | An unadmitted sibling after an admitted write, an unadmitted child under an admitted parent, and an unadmitted form after an admitted write each perform no write at all | | GXC10 | The form at the invocation | An admitted read still performs exactly its read and an admitted write still performs exactly its write, under a `Component.hasContent` handler outside the generated expansion that lies consistently *and* under one answering `[false, true]` / `[true, false]` — with the retained identity and form unmoved, the read's ordinary binding/output behavior intact, and the file bytes proving which effect ran. An admitted read beside each of them reports the chain's answer and consumes exactly one, so every case proves the handler installed and answering before it proves the element ignored it — an inert handler, and an element that consulted the chain and took the next answer, both fail. An invocation the evaluator did not receive is refused with no provider call, and a handler that observes and delegates leaves both forms running through their ordinary providers | -| GXC9 | Continuation | A widened class selection, a widened admitted form, a replaced write identity and an added write identity each refuse the continuation and write nothing; the unchanged policy resumes and performs the write; and a read-only admission survives a changed write table it never selected | +| GXC9 | Continuation | A widened class selection, a widened admitted form, a replaced write identity and an added write identity each refuse the continuation and write nothing; the unchanged policy resumes and performs the write; and a read-only admission survives a changed write selection it never reached | ### Tier GX — Generated continuation in the owning expansion @@ -13157,7 +13180,7 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §§8.4 and 9. | GX14b–GX14c | One ceiling per entry | Two admitted reads each perform exactly their own admitted request, and neither entry's limit admits the other's: the run keys its ceiling table by the entry itself rather than by anything describing one | | FE18/GX21z | The current record restores | The record this build writes resumes against the entry it was admitted under and performs the work it authorized once; the same URL behind an updated implementation resumes, because between executions that is a newer build of the component the admission named rather than a changed grant; and a recorded refusal replays as the refusal it recorded | | FE18/GX21y | One closed record shape | The untagged #369 record and the tagged structural-identity record are each refused as unreadable history. So are a marker this build does not have, legacy top-level roots, an unknown policy, row, result or named member, a row with no form, an unknown form, effect or kind, and an identity that is relative, a look-alike origin, a structural record, an array or null — each with the admitted read never reaching the provider, against the current record as the positive control | -| EP18–EP20 | One name, one implementation | A provider-backed name held by two entries across disjoint forms and both tables is resolved by one lookup and sealed as one shared definition and schema, each spelling reading its own identity and effect off that definition; an answer offering no generated use of an admitted spelling, or offering it as the other effect, refuses at sealing rather than letting a table describe a grant the implementation lacks; and one name held as both a capability and a component answer refuses, because core supplying the body and the chain answering for it are different grants. In `evaluation-profile.test.ts` | +| EP18–EP20 | One name, one implementation | A name selected in both spellings is resolved by one lookup and captured as one shared definition and schema, each spelling reading its own identity and effect off that definition and landing in the table its owner offers it for; an answer offering no generated use of a selected spelling refuses at capture rather than letting a selection describe a grant the implementation lacks; and both spellings offered as reads land in the read table together. In `evaluation-profile.test.ts` | | GOM1–GOM4 | The owner states what its spellings are offered as | A registration's generated map survives registration copied and frozen; an omitted or empty map registers and offers no spelling; a relative, look-alike or non-URL identity, an unknown effect, a key that is not an authored form, a malformed value, a non-map and a spelling the declaration does not accept each refuse, and a refused batch installs nothing. The shipped owners state exactly the owner table, ``'s two spellings differ on one definition, `` stays composition and `` offers nothing. A shared fixed definition is frozen through its nested metadata, so a fresh read sees the original contract. A component a host declares to an execution keeps its offer through capture: the built implementation runs the spelling it offered under the URL and effect the admission retained, the offer is read exactly once so an accessor's later reading reaches neither construction nor the journal, an offer replaced from inside `install()` replaces nothing, and an unusable one refuses the execution before an implementation is built, a provider is asked or anything is written down. In `generated-owner-metadata.test.ts` | ### Tier WGAC — Generated `` effects and the `` boundary @@ -13175,14 +13198,14 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §8.4. | WGAC6 | Distinct sites | Two `` sites keep distinct durable names under a component that rebinds what a component can | | WGAC7 | The import boundary | Through public `importComponent` middleware: calling the implementation with an invocation it built, with the first site's routed at the second, with a live content-bearing parent's routed into the sites inside it, by keeping the declared implementation and running it at another element's invocation, and by keeping one attachment's implementation — with that attachment's whole registration record answered for the name inside a second, simultaneously live attachment — and running it at the second attachment's own `` site, are each refused. Nothing is admitted and no request performed under an identity the author did not write at that site, and neither run's database receives a record the other's expansion named: a redirected registry answer carries behavior, never the claimant this execution delivered. Honest delegation, forwarding the genuine invocation, admits each site under its own name, nested or not; and an interrupted run resumes into each site's own record — a completed read restores and the first interrupted request runs | | WGAC8 | Ordinary output | Evaluate emits exactly what the generated fragment renders. Bound component results emit nothing unless the fragment explicitly renders them, and no read is copied into an implicit result collection | -| WGAC9 | The class selection | Exact core Json remains available from the trusted composition table. An empty, repeated, unknown or non-array `allow` is refused before any admission; `read`, `write` and both in either order are admitted and retained in canonical order; omitting it retains exactly composition plus read, and the write table is not in that policy at all | -| WGAC10 | The standard write table | An admitted paired `` beneath a generated `` first recursively creates the directory through one ordinary `workspace_file` effect, then lands the file at the nested logical path through another; the retained policy holds all three standard entries with their spellings, in the order the profile states them — core's paired `` under `https://executable.md/components/file/write`, paired `` under `.../dir/ensure`, then core's self-closing `` under `.../file/delete`, each in the `write` table — and a `` and a self-closing `` are each refused under the same selection with no effect | +| WGAC9 | The class selection | Exact core Json remains available from the trusted composition table. An empty, repeated, unknown or non-array `allow` is refused before any admission; `read`, `write` and both in either order are admitted and retained in canonical order; omitting it retains exactly composition plus read, and the writing spellings are not in that policy at all | +| WGAC10 | The standard writing spellings | An admitted paired `` beneath a generated `` first recursively creates the directory through one ordinary `workspace_file` effect, then lands the file at the nested logical path through another; the retained policy holds all three standard rows with their spellings, in the canonical order the selection is admitted in — paired `` under `https://executable.md/components/dir/ensure`, core's paired `` under `.../file/write`, then core's self-closing `` under `.../file/delete`, each as a `write` — and a `` and a self-closing `` are each refused under the same selection with no effect | | WGAC14 | The admitted deletion | A write-only fragment holding one self-closing `` removes the file from the run's own Workspace, read back through the run's own transaction; it performs exactly one further `workspace_file` effect, whose outcome is `{ kind: "deleted" }`; it is retained under `@executablemd/core#File.Delete` in the self-closing form; and it renders no synthetic receipt | | WGAC15 | Preflight covers the deletion | A fragment placing an admitted `` first and an executable code block second is refused for the block — the class proving the deletion was admitted, since a refusal names no name — and the file remains with no deletion effect performed or retained | | WGAC11 | Explicit composition | A fragment binds read results locally, composes selected values through Json, and renders no value or mutation receipt it did not explicitly render | | WGAC12 | Committed mutations | A completed replay of a write-enabled document journals nothing new, performs no second mutation, and leaves the retained content | | WGAC16 | Bundled continuation | `` inside a committed bundled Markdown component, then a parent `` write and the real `` outside it, for a generated read and a generated write alike: the real start suspends holding the exactly ordered `generated_xmd → nested workspace_file → parent workspace_file → suspension_request` subsequence; answer delivery and the completed resume leave the journal counts, that subsequence, the Workspace root-publication count and the authoritative current root unchanged; and the delivered value reaches the document after the wait. `API.Files` component calls are not evidence here — re-expansion legitimately enters that boundary before the durable effect restores | -| WGAC18 | A generated question is the host's to admit | This host's generated write table states no question, so a fragment writing `` under `allow={["write"]}` is refused for a component it did not admit, with nothing asked and nothing performed; a host that states the entry admits the same fragment. The workflow's own suspension replacement for the authored element is unaffected either way — it answers an authored `` in a run, and a generated one never resolves a name for it to shadow | +| WGAC18 | A generated question is the host's to offer | This host's generated selection names no question, so a fragment writing `` under `allow={["write"]}` is refused for a component it did not offer, with nothing asked and nothing performed; a host that offers the name admits the same fragment. A host whose own replacement answers for `Elicit` and offers no generated use of the paired form refuses setup rather than reaching core's default | | WGAC17 | Directory mutation permission | `allow={["write"]}` admits the paired `` under the identity core's own declaration offers it as, and intentionally authorizes its persistent recursive creation; a continuation retaining another identity for that spelling refuses before generated execution and creates nothing, without publishing which identity moved, while an unchanged current admission replays without a second ensure | ### Tier WAL — The workflow Agent observation loop @@ -13758,7 +13781,7 @@ subject. | FT18–FT20 | The binding seam | `hasBinding()` answers for the invocation that asked — siblings, a nested invocation inside its caller, and two invocations live at once | | FT21–FT24 | Failures | Transport, body read, timeout and cancellation bind nothing and commit no response; a halt tears the provider down in both phases with no late work | | FT25–FT28 | Permission | Middleware may observe and delegate but cannot widen; a synthetic answer performs no request; eval's own `fetch` and a same-name repository component cross the same ceiling | -| GX11–GX14 | Generated admission | The pinned identity in the host's `read` table performs the exact admitted request once; a scheme, host, path, method, header or timeout mismatch performs none; admitting `` with no stated request is refused outright | +| GX11 | Generated admission | A selected `` performs its request once through the ordinary component; which destination, method and headers it may reach is the live terminal's question, proved against a host-stated `Permissions` bound rather than against an inventory the admission retains | | FT29–FT31 | History | A partial replay restores the response with no second request; the event names the expansion and its source position; an interruption before the commit leaves no record and one continuation commits one | | FT32–FT34 | The secret gate | The scanner sees URL, request headers, status, response headers and body in one event; a canary in the request or the response refuses the append, binds nothing, and stops the document | | FT35 | Independence | Cancelling one invocation tears down only its own request | @@ -14136,4 +14159,4 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 100 | `` → `` → `` is the explicit JSON direction | A document names a value, renders it as text, and validates text back into a value at three boundaries a reader can see. `{binding}` interpolation keeps its ordinary string coercion — there is no hidden JSON conversion — and `` takes no `indent`, `pretty`, replacer, sorting, canonicalization or newline option, so nothing about the format has to be agreed on per invocation. A file that must end in a newline authors that newline at the point it is written, rather than buying an option every other caller then has to reason about | | 101 | A capture is delivered from the authored expression | The scanner resolves a `{…}` prop whose text reads as JSON, but it runs before a name resolves and so cannot know the prop is a capture. Keeping the authored text beside the reading lets expansion decide with the selected definition in hand: a captured prop gets what its expression produced, every other prop gets the reading it always had, and an overriding repository file is an ordinary component either way. Without it `value={undefined}` reached a capturing component as `null`, which is the projection a capture exists to avoid | | 102 | A successful `undefined` omits an ordinary prop | Absence is the fact an author has to be able to state: an optional identity that a preceding result will supply is not there yet, and there is no value that means "not there" — `null` is a value, and a second element written for the other case duplicates the invocation. The omission is decided in the one resolver both component kinds pass through, before validation, so the schema answers it: optional stays unset, a `default` applies, a required prop fails as missing. It stops at the root and at that boundary — nested members keep native `JSON.stringify` normalization, failures stay failures, and captures and `` keep binding the exact value — and it puts `undefined` nowhere durable, because a prop that was never there is nothing to record | -| 103 | Protected `` and an authored loop, not hidden `` behavior | A workflow Agent's request/result exchange is written in the document: `` owns the bound, `` and `` own the branch, and `` is where a generated fragment is admitted and performed. `` stays exactly one durable turn, with no hidden retry, schema repair or observation loop inside it, and the Agent Api gains no second operation. A component rather than a new execution primitive, because the operation needs a name a trusted document can write and a schema a reader can check. Canonical core supplies [protected Evaluate](#532-evaluate-the-protected-evaluation-component); the host states its evaluation profile, captured before any document existed. Protection fixes the implementation and grants no permission; every ceiling comes from the captured profile. Exhaustion is therefore the document's own failure, stated where the bound is, with the turns that produced it retained | +| 103 | Protected `` and an authored loop, not hidden `` behavior | A workflow Agent's request/result exchange is written in the document: `` owns the bound, `` and `` own the branch, and `` is where a generated fragment is admitted and performed. `` stays exactly one durable turn, with no hidden retry, schema repair or observation loop inside it, and the Agent Api gains no second operation. A component rather than a new execution primitive, because the operation needs a name a trusted document can write and a schema a reader can check. Canonical core supplies [protected Evaluate](#532-evaluate-the-protected-evaluation-component); the host offers its generated selection, read and resolved before public execution policy. Protection fixes the implementation and grants no permission; every ceiling comes from that captured selection and the declarations that own the implementations it named. Exhaustion is therefore the document's own failure, stated where the bound is, with the turns that produced it retained | diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 2a8729d56..20d1eb6c5 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -216,6 +216,13 @@ write table, so a generated program can preview what it is about to do and ask before doing it — and its validated answer drives what follows through ordinary bindings. +The ordinary ceiling is the command's default rather than the entry's answer. The +session offers it in each entry's own execution scope as ordinary middleware that +delegates first and supplies the default only where nothing nearer stated one, so +a plugin or host installation that states its own selection is the one that entry +runs under — including a defined empty one, which refuses the entry's setup +instead of being replaced. + ## Sessions: one chronology, and which conversation you are reading The Sessions surface shows every Agent turn this execution has, in one order: diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 3ed901381..888e164ff 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -1111,7 +1111,7 @@ children for, or refuse a paired one that has them (executable-mdx-spec §5.6). A Prompt sent to an already-established Agent does not acquire a new cwd merely because its invocation appears inside a later Dir. -Its paired form is also what the standard generated-XMD write table admits +Its paired form is also what the standard generated-XMD selection offers (§8.4), built from this same implementation and schema, and offered for that use by canonical core's own declaration: `https://executable.md/components/dir/ensure`, as a write (`specs/executable-mdx-spec.md` §6.13.2). Selecting @@ -1836,37 +1836,46 @@ current sandbox. A Prompt may return an XMD fragment rather than an ad hoc file-operation schema. The fragment is untrusted input: it carries data, never authorization. Only the -trusted workflow host installs the admission, and it does so by handing the -evaluator values it holds — never through a context, a registration, a document -prop or a component name. - -#### The host states two tables, and the caller selects between them - -Host policy holds a **read** table and a **write** table. Each entry is an exact -pinned definition together with the authored forms it is admitted for and, for a -read that performs one, the exact requests it may make. Both are values the -trusted host captured before any document existed. +trusted workflow host offers the vocabulary, and it does so through the ordinary +`Component.generated` property — never through a context value a document could +replace, a registration, a document prop or a component name. + +#### The host offers names and forms, and the caller selects a class + +The host's whole contribution is a selection: a map from component name to the +authored forms that name is offered in. The generic workflow selection is core's +`` in both spellings and its self-closing ``. A host that +wants more offers the names it registered or its providers answer for; the CLI +workflow adds paired `` from the bundled Git Plugin, and self-closing +`` only where it also bounds what a request may reach. A generic host +that chose neither has neither. + +Canonical execution reads that selection once — after initialization and the +captured installation callbacks, before public `Execution.execute` policy — +validates all of it, resolves each selected name once through the ordinary +import chain, and retains the implementation together with its declaration's +metadata. What each selected spelling runs as, and which class it reaches, +is that declaration's (`specs/executable-mdx-spec.md` §5.3). An authored `allow` selects a non-empty, duplicate-free subset of the closed classes `read` and `write`, and the selection normalizes to canonical order — `read` before `write` — because two documents asking for the same classes are asking for one grant. Omitting `allow` selects exactly `read`, so a document that states no class asks for observation alone. An empty, repeated or unknown -selection, and a selected class the host supplied no table for, each fail before +selection, and a selected class no selected spelling reaches, each fail before the candidate is parsed and before any `generated_xmd` record exists. -`allow` narrows; it never grants. It cannot admit a name or a spelling, add a -root, authorize a destination, attach a credential or widen a request. A host -that installed no write table refuses `allow={["write"]}` outright. +`allow` narrows; it never grants. It cannot admit a name or a spelling, +authorize a destination, attach a credential or widen a request. A run whose +selected spellings all read refuses `allow={["write"]}` outright. #### Admission -The host supplies the exact candidate source, the retained Workspace roots the -run has and which one is selected, the immutable read and write tables of -admitted components, the exact requests each admitted read may perform, and the -classes this fragment draws on. What each admitted spelling runs as — its -identity and its effect — comes from the declaration that owns its -implementation rather than from the table (`specs/executable-mdx-spec.md` §5.3). Before the first effect, the evaluator: +The evaluator is given the exact candidate source, the retained implementations +each selected name resolved to with the forms it was selected for, and the +classes this fragment draws on. It is given no Workspace basis and no request +inventory: what the run stands on and what a request may reach are both live +state. Before the first effect, the evaluator: - parses the complete source; - walks every element and every element's content; @@ -1876,9 +1885,24 @@ implementation rather than from the table (`specs/executable-mdx-spec.md` §5.3) - permits ordinary structural constructs, fragment-local `as`/`Let` bindings and declarative data-expression props, while refusing executable code blocks, arbitrary JavaScript, prose interpolation that reads a binding, unadmitted - components and forms outside the selected tables; and -- refuses a request that is malformed, or that does not normalize to one the - host stated exactly. + components and forms outside the selected classes. + +Whether an admitted `` may reach the destination it names is **not** +asked here. A host that bounds its requests states that bound as a +`Permissions` restriction in the owner that performs the work, where it is asked +of the request as it would go out — after every `API.Fetch` transformation and +immediately before transport. So a dynamic destination the fragment's own data +expressions produce is an ordinary input, and a forbidden destination, method or +header reaches no transport at all. + +The bound is the complete request: the destination, the method, and the headers +whole, with nothing missing and nothing extra. Header names are compared +case-insensitively, and a request that names one header twice is refused rather +than normalized — a transport joins differently cased occurrences into a single +sent header, so neither occurrence is what goes out and no reading of such a +request describes it. The same rule holds where the host states the bound: a +bound that names one header twice is refused where it is installed, and offers +no selection under it. Data expressions compose JSON literals, finite numbers, local binding identifiers, arrays, objects and object shorthand; they do not execute @@ -1890,12 +1914,11 @@ binding escapes it. A refusal ends the whole fragment. Its diagnostic names the construct class, and so does what the run retains of it, so a rejected fragment publishes nothing — -including a credential a rejected element carried. A malformed request is -refused on the same terms as an unadmitted one: the ordinary component reports a -bad URL, header or timeout by quoting it, and a generated request may not be -quoted anywhere. The host's own ceilings are a different kind of value — -normalized before the admission, and reported as themselves when the host -states one it cannot mean. +including a credential a rejected element carried. A request the fragment +malformed is the component's own refusal, reported where the request is made: +that is after the admission, which is the line past which the exact source is +retained on purpose, so there is no pre-admission diagnostic for it to escape +through. Admission is the line. Before it nothing of the candidate is retained; after it the exact source is retained deliberately, so an ordinary expansion diagnostic @@ -1903,11 +1926,13 @@ naming part of the fragment discloses nothing the journal does not already hold. One ordinary durable `generated_xmd` event records the decision. An admission -carries the exact source, the canonical class selection, the retained roots and -the selected one, every entry of the selected tables as a name, identity and -admitted forms, the identity and form of each element the fragment named, and -the normalized request policy it ran under; a refusal carries the construct -class and nothing else. The whole event crosses the journal's secret filter like +carries the exact source, the canonical class selection, one row per selected +spelling — its name, the authored form, the identity that form runs under, what +kind of thing answered for the name and which class admitted it — and the +identity and form of each element the fragment named; a refusal carries the +construct class and nothing else. It carries no Workspace basis and no request +inventory: both are live, and a grant compared against a copy of either would +refuse a run for its own progress. The whole event crosses the journal's secret filter like any other, and it commits before the first generated effect. The effects themselves are retained by their ordinary records — `fetch` for ``, `workspace_file` for `` (§10.1) — and the admission and every nested @@ -1924,22 +1949,24 @@ An interruption before an observation's own record commits retains no partial observation; a later continuation may perform that read once more under the same retained admission. -#### A continuation is held to the ceilings it was admitted under +#### A continuation is held to the selection it was admitted under A continuation uses the exact source and selected policy retained by the admission, not a newly supplied candidate. It checks them before invoking a generated component or performing a request. -Root checks preserve the run's own progress: every admission root, including -its selected root, must remain retained, and the current root must be retained. -Additional roots and an advanced current root pass. Losing an admission root -refuses without revealing compared values. +Every term must match exactly in canonical order: the selected classes, and the +admitted spellings with the identity each runs under and the class that admitted +it. Only the classes the selection reached participate, so changing a writing +spelling a read-only admission never drew on does not invalidate it. +[Generated admission and continuation](../architecture.md#agent-permissions-and-generated-xmd) +owns the retained policy's shape. -Non-root terms must match exactly in canonical order: selected classes, the -admitted spellings with the identity each runs under, and normalized request -ceilings. Only selected tables participate, so changing an unused write table -does not invalidate a read-only admission. [Generated admission and continuation](../architecture.md#agent-permissions-and-generated-xmd) -owns the retained-basis comparison and the retained policy's shape. +The run's own progress is deliberately not a term. A committed mutation retains +another immutable root and advances the frontier, and a continuation compared +against a retained copy of that frontier would refuse a run for exactly the work +it was admitted to do. So is a destination policy: it is asked live, where the +request is performed. `File` is admitted as two spellings, never as one. Its own declaration offers the self-closing form as a **read** and the paired form — including the empty @@ -1951,10 +1978,10 @@ body. A paired `` under a `read`-only selection, and a self-closing fragment performs no read and no write at all. A repository component named `File` is admitted as neither spelling. -A name resolves to one implementation. Two entries under one name must therefore -carry the same definition and disjoint forms; overlapping forms or two -definitions are a malformed host table, refused before anything is retained -rather than settled by whichever entry the host listed first. +A name resolves to one implementation, however many spellings it was selected +for: both carry the same captured definition, and which class each reaches is +its owner's answer. A spelling the owner offers no generated use of refuses at +capture rather than acquiring a grant from the selection that named it. **The admitted form also holds at invocation.** Preflight selects the exact name and spelling before the first effect; canonical dispatch then holds the @@ -2054,10 +2081,10 @@ working directory, which is the Workspace root as narrowed by the run's own invoked `xmd` from. What keeps a fragment's permission bounded is therefore not which object -answered. It is the entry tables — which names, in which spelling, under which -identity a continuation is compared against — the ceiling an admitted `` -carries, the policy in force where the element runs, and the run's own logical -filesystem, which contains no host path to name. +answered. It is the selection — which names, in which spelling, under which +identity a continuation is compared against — the live `Permissions` in force +where the element runs, and the run's own logical filesystem, which contains no +host path to name. **Each occurrence owns its durable operation.** Canonical execution authenticates the Evaluate invocation; a Context, contextual Api answer, @@ -2071,12 +2098,11 @@ observation stops the authored loop unless the document places a recovery boundary around it, because returning a refusal as observation text would leave the Agent reasoning from a read that never happened. -The host captures one immutable evaluation profile before installation; -Evaluate reads retained roots and the authoritative current root from the owning -run at invocation. No prop, binding, Context, contextual API answer, -registration, generated name or middleware result supplies or widens the -ceiling. Protection fixes the component, while contextual Files and Permissions -still govern its operations. +The host offers one selection, which canonical execution reads once before +public execution policy and resolves there. No prop, binding, registration, +generated name or later middleware result supplies or widens it. Protection +fixes the component, while contextual Files and Permissions still govern its +operations. The [canonical protected tier](../architecture.md#the-canonical-protected-tier) owns import enforcement: middleware may observe, delegate or refuse the import, @@ -2086,18 +2112,20 @@ admission tables; naming `Evaluate` there is an unadmitted-component refusal. #### Mutation-proposal admission Mutation admission is the same boundary asked for the other class. The standard -Deno workflow profile's write table admits exactly three spellings, in the order -it states them: core's paired ``, the paired `` the Git Plugin asks -for — a host deciding that a workflow run has one at all, while core supplies -both the body and the identity it runs under — and core's self-closing -``. A host's own extensions come after them, and the profile -supplies no external-write and no execution table. - -That order is retained identity. A continuation compares the entries position by -position ([§8.4, continuation ceilings](#a-continuation-is-held-to-the-ceilings-it-was-admitted-under)), so a run admitted under -a table with fewer entries is refused before any generated effect rather than -resumed under the wider one. A read-only admission never selected the write table -and is unaffected by a change to it. +Deno workflow run selects exactly three writing spellings: core's paired +``, the paired `` the Git Plugin asks for — a host deciding that a +workflow run has one at all, while core supplies both the body and the identity +it runs under — and core's self-closing ``. A host's own extensions +are names it answers for, and the selection offers no external-write and no +execution class at all. + +The retained rows are the identity, in the canonical order the selection is +admitted in rather than in whatever order a host's object happened to enumerate. +A continuation compares them row by row +([§8.4, continuation](#a-continuation-is-held-to-the-selection-it-was-admitted-under)), so a run admitted +under a narrower selection is refused before any generated effect rather than +resumed under the wider one. A read-only admission never reached the writing +spellings and is unaffected by a change to them. An admitted mutation runs as the ordinary component it is. A paired `` and a paired `` and a self-closing `` each cross `API.Files`, which @@ -2112,16 +2140,16 @@ owns atomicity, failure and cancellation. Selecting `allow={["write"]}` therefore intentionally authorizes persistent, recursive directory creation by the admitted ``. A continuation whose -retained table holds another identity, spelling or effect behind that name -refuses before any generated component executes; the write ceiling is not -silently broadened. +retained rows hold another identity, spelling or effect behind that name refuses +before any generated component executes; the write ceiling is not silently +broadened. Because the whole fragment is decided inside the admission before its first effect, a fragment mixing an admitted write with anything unadmitted — a later sibling, a nested child, or the same name in the other form — performs no write at all. -The write table is permission, not prompting guidance. Generated source cannot +The writing spellings are permission, not prompting guidance. Generated source cannot grant itself Push, PullRequest, an issue upsert, a repository, a process, an eval or exec block, a native command, a credential or an arbitrary network write merely by naming a component; the table excludes local Git even though those @@ -2345,7 +2373,7 @@ capturing the root the run already had. | File read | restore historical content | | File write/delete | restore completion without mutating again; a deletion whose effect was never published is performed once by the continuation, against the retained frontier | | Glob | restore historical path set | -| generated XMD | restore the retained admission — every admission root and its selected root still retained, every non-root ceiling exact — then offer each nested generated effect inline in authored order: a completed read or mutation restores without another live execution or root publication, an uncommitted one is performed once by the continuation, and the sequence continues to later parent effects and any durable wait | +| generated XMD | restore the retained admission — every selected row exact, and nothing about the run's own frontier compared — then offer each nested generated effect inline in authored order: a completed read or mutation restores without another live execution or root publication, an uncommitted one is performed once by the continuation, and the sequence continues to later parent effects and any durable wait | | Prompt/Sample | restore response | | suspension request | restore its filtered request; with no input, settle the new execution `suspended` and release the executor lock again | | suspension answer | restore the delivered value without reaching the live controller and without consuming retained delivery state again | @@ -2499,7 +2527,7 @@ otherwise remove. It renders nothing and returns nothing. Each deletion is one `workspace_file` effect, named by the expansion, the operation and the resolved logical target, and its retained outcome is exactly `{ kind: "deleted" }` — including the no-op. A deletion an admitted generated -fragment performs is that same effect: the standard write table admits the +fragment performs is that same effect: the standard selection offers the ordinary component (§8.4), so there is one account of the removal and the evaluator adds nothing beside it. That is provider-facing protocol data rather than a value a document can read. A refusal retains the same From dd6a610574e645ca847d53923c6cf5995015c47a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 15 Oct 2026 16:30:45 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20Offer=20a=20workflow=20repla?= =?UTF-8?q?y=20no=20selection=20rather=20than=20the=20command's=20default?= =?UTF-8?q?=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `xmd workflow` execution that this host offers no generated selection — a completed replay, and both passes of a fork's compatibility preflight — was executed under the ordinary `xmd run` default instead. That default is contextual middleware installed in the scope enclosing every command, and it answers wherever nothing nearer does, so an execution that stated nothing inherited a vocabulary the workflow host never offered. With a host that registers the suspension ``, the leaked default then selected a name whose implementation in that run offers no generated use, and the whole fork was refused: the candidate definition diverges from the inherited history after 0 of 4 events, at the fork's own run record (workflow_run): a host selected "Elicit" in its paired form, and the declaration that owns that implementation offers no generated use of it. Each such execution now states `undefined` for itself — the answer core starts from, and what this command's installations stated before the selection became contextual. `PRR27` in `packages/cli/tests/pull-request-read-fork.test.ts` is the end-to-end evidence: it forks a run that already read a pull request, and it fails without this. `offering()` and the new statement live in `workflow-generated.ts` because the command imports the fork preflight, so the preflight cannot import the command back. --- packages/cli/src/workflow-fork.ts | 7 +++- packages/cli/src/workflow-generated.ts | 48 ++++++++++++++++++++++++++ packages/cli/src/workflow.ts | 20 +++-------- 3 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/workflow-generated.ts diff --git a/packages/cli/src/workflow-fork.ts b/packages/cli/src/workflow-fork.ts index 31c694d73..aeaa415b5 100644 --- a/packages/cli/src/workflow-fork.ts +++ b/packages/cli/src/workflow-fork.ts @@ -53,6 +53,7 @@ import type { Operation, Result } from "effection"; import { ReplayGuard } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream, ReplayOutcome } from "@executablemd/durable-streams"; import { retainedSource } from "@executablemd/core"; +import { offering, useNoGeneratedEvaluation } from "./workflow-generated.ts"; import { forkJournal, forkRunRecordEvent, @@ -342,7 +343,11 @@ function execution( // A check, not a run: what this renders describes work another run already // did, and the fork's own execution is about to render it again. discardOutput: true, - around, + // And a check is offered no generated evaluation, which it states for + // itself: the command's ordinary default encloses this preflight, and a + // replay held to another run's history must not resolve a vocabulary that + // run never had. + around: (operation: Operation) => around(offering(operation, useNoGeneratedEvaluation)), }; } diff --git a/packages/cli/src/workflow-generated.ts b/packages/cli/src/workflow-generated.ts new file mode 100644 index 000000000..b22e3037b --- /dev/null +++ b/packages/cli/src/workflow-generated.ts @@ -0,0 +1,48 @@ +/** + * What a workflow command offers one execution, and how it offers it. + * + * A workflow run's generated selection is the Workflow host's own: it is + * offered inside the Workspace attachment, in the scope that executes the + * document. The two helpers here are what every execution a workflow command + * builds goes through, so that *whether* an execution is offered a selection is + * stated at each site rather than inherited from whatever encloses it. + * + * It matters because `xmd` installs the ordinary `xmd run` selection in the + * scope that encloses every command, as the ordinary contextual default. A + * default answers where nothing nearer does — which is correct for a document + * an ordinary run executes, and wrong for a workflow execution this host + * offered nothing: a compatibility replay or a completed replay would then be + * executed under a vocabulary the workflow host never stated. + * + * Shared by `workflow.ts` and `workflow-fork.ts` rather than exported from + * either: the command imports the fork preflight, so the preflight cannot + * import the command back. + */ + +import { Component } from "@executablemd/core/api"; +import type { Operation } from "effection"; + +/** + * Install one host setup operation, then run the document in that same scope. + * + * Ordinary mechanics rather than a capture protocol: what a selection answers + * has to be composed in a scope that is still open when canonical execution + * reads it, and the attachment chain is built from operations rather than from + * scopes a caller can reach into. + */ +export function* offering(operation: Operation, setup: () => Operation): Operation { + yield* setup(); + return yield* operation; +} + +/** + * State that this execution offers no generated evaluation at all. + * + * `undefined` is the answer core itself starts from, so an execution that + * states it is an execution with no generated evaluation — which `` + * reports where it is written rather than at startup. Stated rather than left + * absent, because absence is what an enclosing default answers. + */ +export function useNoGeneratedEvaluation(): Operation { + return Component.around({ generated: () => undefined }); +} diff --git a/packages/cli/src/workflow.ts b/packages/cli/src/workflow.ts index c25390892..759cfa7be 100644 --- a/packages/cli/src/workflow.ts +++ b/packages/cli/src/workflow.ts @@ -95,6 +95,7 @@ import type { import type { SuspensionControllerOptions, SuspensionNotice } from "@executablemd/workflow/deno"; import { SUSPENSION_REQUEST } from "@executablemd/workflow"; import { describeError } from "./props.ts"; +import { offering, useNoGeneratedEvaluation } from "./workflow-generated.ts"; import { preflightFork } from "./workflow-fork.ts"; import type { EstablishedDefinition, RetainedSources } from "./workflow-definition.ts"; import type { RetainedDefinitionSources } from "@executablemd/workflow/deno"; @@ -383,19 +384,6 @@ export interface WorkflowExecution { around(operation: Operation): Operation; } -/** - * Install one host setup operation, then run the document in that same scope. - * - * Ordinary mechanics rather than a capture protocol: what a selection answers - * has to be composed in a scope that is still open when canonical execution - * reads it, and the attachment chain is built from operations rather than from - * scopes a caller can reach into. - */ -function* offering(operation: Operation, setup: () => Operation): Operation { - yield* setup(); - return yield* operation; -} - /** How one `start` or `resume` ended. */ export interface WorkflowOutcome { readonly exitCode: number; @@ -1097,12 +1085,14 @@ export function runWorkflow( // // The generated selection is offered inside the attachment, in the // scope that executes the document: a completed replay restores its - // retained output and expands nothing, so it is offered nothing. + // retained output and expands nothing, so it is offered nothing — and + // states that, because this command's ordinary default encloses it and + // a run offered nothing is not a run that inherits a vocabulary. // ``'s spelling comes from the Git Plugin, which decides that a // workflow run offers the name at all; the identity it runs under is // core's, from the declaration that owns the component. return completed || replay - ? suspension.own(operation) + ? suspension.own(offering(operation, useNoGeneratedEvaluation)) : host.attach( database, suspension.own( From dcd9cde33e8e57f789a860ce8685651ffaf9eca2 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 15 Oct 2026 16:30:52 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=9A=A8=20Sort=20through=20`sort()`=20?= =?UTF-8?q?where=20the=20Node=20typecheck's=20lib=20is=20ES2022=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of this stack's new assertions sorted with `toSorted()`, which is ES2023. `deno check` accepts it and `pnpm exec tsc --project tsconfig.node.json` does not, so every `test-node` shard failed before running a test: error TS2550: Property 'toSorted' does not exist on type 'string[]'. `Object.keys()` hands back a fresh array, so sorting it in place moves nothing a caller holds — which is what the rows beside these already do, for this reason. --- packages/cli/tests/evaluate-workflow.test.ts | 2 +- packages/cli/tests/plan-command-document.test.ts | 2 +- packages/workflow/tests/generated-agent-component.test.ts | 6 +----- packages/workflow/tests/generated-observations.test.ts | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/cli/tests/evaluate-workflow.test.ts b/packages/cli/tests/evaluate-workflow.test.ts index 07db73d25..6f5477341 100644 --- a/packages/cli/tests/evaluate-workflow.test.ts +++ b/packages/cli/tests/evaluate-workflow.test.ts @@ -93,7 +93,7 @@ describe("Tier FE — the workflow host's profile", () => { expect(Object.keys(generic)).not.toContain("Fetch"); const bundled = workflowGeneratedSelection({ components: gitDirectorySelection() }); - expect(Object.keys(bundled).toSorted()).toEqual(["Dir", "File", "File.Delete"]); + expect(Object.keys(bundled).sort()).toEqual(["Dir", "File", "File.Delete"]); expect(bundled.Dir).toEqual(["paired"]); yield* useScope(); }); diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index 9d6a978aa..69623d7bd 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -1352,7 +1352,7 @@ describe("Tier PI — the ordinary selection offers the question with the write" // declaration, which offers that form as a write — so `allow={["read"]}` // promises nobody will be interrupted without this file deciding it. expect(selection.Elicit).toEqual(["paired"]); - expect(Object.keys(selection).toSorted()).toEqual([ + expect(Object.keys(selection).sort()).toEqual([ "Elicit", "File", "File.Delete", diff --git a/packages/workflow/tests/generated-agent-component.test.ts b/packages/workflow/tests/generated-agent-component.test.ts index 0fab5c935..28be4fedb 100644 --- a/packages/workflow/tests/generated-agent-component.test.ts +++ b/packages/workflow/tests/generated-agent-component.test.ts @@ -484,11 +484,7 @@ describe("Tier WGAC — the registered Evaluate component", () => { const recorded = admissions(attempt.events); expect(recorded).toHaveLength(2); for (const event of recorded) { - expect(Object.keys(policyOf(event) ?? {}).toSorted()).toEqual([ - "allow", - "allowed", - "version", - ]); + expect(Object.keys(policyOf(event) ?? {}).sort()).toEqual(["allow", "allowed", "version"]); } // And the run did move: the write between them advanced the current root. diff --git a/packages/workflow/tests/generated-observations.test.ts b/packages/workflow/tests/generated-observations.test.ts index 689f47e02..314714347 100644 --- a/packages/workflow/tests/generated-observations.test.ts +++ b/packages/workflow/tests/generated-observations.test.ts @@ -277,7 +277,7 @@ describe("Tier WGX — the selection a workflow run offers", () => { // the rows. No Workspace basis and no request inventory — what a run stands // on and what a request may reach are both live state, and a grant compared // against a copy of either would refuse a run for its own progress. - expect(Object.keys(policy ?? {}).toSorted()).toEqual(["allow", "allowed", "version"]); + expect(Object.keys(policy ?? {}).sort()).toEqual(["allow", "allowed", "version"]); expect(policy?.version).toBe(4); expect(policy?.allow).toEqual(["read"]); expect(policy?.allowed).toEqual([ From 326afc60b323f0adc3064b12cdc22588353ed83f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 15 Oct 2026 17:03:03 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=92=20Declare=20generated=20use=20?= =?UTF-8?q?where=20a=20component=20is=20implemented=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host selects a name, and canonical execution resolves it through the ordinary import chain — so a repository component on the search path is what a selected name means. Those files could state nothing about generated use: the metadata for ``, `` and `` lived in core's registry, beside the registration rather than on the component. Core's own component sources on a search path were therefore a *different* declaration from the registration of the same implementation, and selecting the name refused the run: xmd test packages/core/src → 10 of 10 documents failed xmd -e '' --include packages/core/src/components → a host selected "Elicit" in its paired form, and the declaration that owns that implementation offers no generated use of it. Both are shipped commands: `xmd test ` puts each document's own directory on the search path, and CI smoke-tests the built-ins that way. A function component may now export `generated`, read through the same `admitGeneratedForms()` validator a registration goes through and held to the forms the component accepts. `Elicit.ts`, `Glob.ts` and `Fetch.ts` state theirs there, and the registry reads those values instead of restating them — one declaration per component, whichever way an execution reached it. A module that exports none is offered for no generated use, exactly as before; one that exports something unusable refuses where it is loaded. Exporting it is not permission. A fragment still needs the host's selection, the authored form, the `allow` class and whole-fragment admission, and every effect still crosses the installed providers and the effective `Permissions`. `U5-3: a repository component is offered for what it declares, and no more` drives both halves through the real loader: the offered copy runs in the fragment, and a same-shaped copy that declares nothing refuses the selection. --- packages/core/src/components/Elicit.ts | 13 +++ packages/core/src/components/Fetch.ts | 13 +++ packages/core/src/components/Glob.ts | 13 +++ .../core/src/components/generated-metadata.ts | 7 +- packages/core/src/components/registry.ts | 26 +++--- packages/core/src/execute.ts | 15 +++- .../core/tests/generated-selection.test.ts | 83 +++++++++++++++++++ specs/executable-mdx-spec.md | 13 +++ 8 files changed, 167 insertions(+), 16 deletions(-) diff --git a/packages/core/src/components/Elicit.ts b/packages/core/src/components/Elicit.ts index d76146bb5..ccad34416 100644 --- a/packages/core/src/components/Elicit.ts +++ b/packages/core/src/components/Elicit.ts @@ -70,6 +70,19 @@ export const props = { * object's properties (§5.1.1), so a bare `{}` would declare an object with no * properties — the opposite of any value. */ +/** + * The generated use this component is offered for, where it is implemented. + * + * Stated here rather than only at the registration, because a component is one + * declaration wherever it is loaded from: core's registry reads this, and so + * does a repository `Elicit.ts` on a search path, which is the same + * implementation reached the other way. A host that selects the name is then + * offered the same thing by whichever copy answers. + */ +export const generated = { + paired: { identity: "https://executable.md/components/elicit/ask", effect: "write" }, +} as const; + export const returns = { $schema: "http://json-schema.org/draft-07/schema#", } satisfies ReturnsSchema; diff --git a/packages/core/src/components/Fetch.ts b/packages/core/src/components/Fetch.ts index 5c44a4d4f..e06cf4472 100644 --- a/packages/core/src/components/Fetch.ts +++ b/packages/core/src/components/Fetch.ts @@ -41,6 +41,19 @@ import type { FetchResponseRecord } from "../fetch-response.ts"; import { persistFetch } from "../fetch-journal.ts"; import type { Json } from "../types.ts"; +/** + * The generated use this component is offered for, where it is implemented. + * + * Stated here rather than only at the registration, because a component is one + * declaration wherever it is loaded from: core's registry reads this, and so + * does a repository `Fetch.ts` on a search path, which is the same + * implementation reached the other way. A host that selects the name is then + * offered the same thing by whichever copy answers. + */ +export const generated = { + "self-closing": { identity: "https://executable.md/components/fetch/read", effect: "read" }, +} as const; + export const props = { type: "object", properties: { diff --git a/packages/core/src/components/Glob.ts b/packages/core/src/components/Glob.ts index 92b7f52f6..347023035 100644 --- a/packages/core/src/components/Glob.ts +++ b/packages/core/src/components/Glob.ts @@ -52,6 +52,19 @@ import type { Json } from "../types.ts"; export const props = GLOB_PROPS; +/** + * The generated use this component is offered for, where it is implemented. + * + * Stated here rather than only at the registration, because a component is one + * declaration wherever it is loaded from: core's registry reads this, and so + * does a repository `Glob.ts` on a search path, which is the same + * implementation reached the other way. A host that selects the name is then + * offered the same thing by whichever copy answers. + */ +export const generated = { + "self-closing": { identity: "https://executable.md/components/glob/read", effect: "read" }, +} as const; + /** * Declaring `returns` is what makes this a value component: the engine requires * `as`, renders nothing, and validates what comes back (§6.10). diff --git a/packages/core/src/components/generated-metadata.ts b/packages/core/src/components/generated-metadata.ts index 7029dd88b..a92c88e48 100644 --- a/packages/core/src/components/generated-metadata.ts +++ b/packages/core/src/components/generated-metadata.ts @@ -144,9 +144,10 @@ function read(value: unknown, form: InvocationForm): Result { * Why this metadata is unusable, or nothing when it is usable. * * The sentence alone, for a caller that refuses rather than retains: a - * registration, a factory declaration and a resolved provider definition each - * raise their own kind of failure with their own diagnostic context, and a - * shared throw here would take that context away from all three. + * registration, a factory declaration, a repository component file and a + * resolved provider definition each raise their own kind of failure with their + * own diagnostic context, and a shared throw here would take that context away + * from all of them. */ export function generatedRefusal( generated: unknown, diff --git a/packages/core/src/components/registry.ts b/packages/core/src/components/registry.ts index 415440402..dce48adfc 100644 --- a/packages/core/src/components/registry.ts +++ b/packages/core/src/components/registry.ts @@ -23,11 +23,19 @@ import type { ComponentRegistry, FunctionComponentDefinition, RegistryEntry } from "../types.ts"; import { form as codeBlockForm, props as codeBlockProps } from "./CodeBlock.ts"; -import Elicit, { props as elicitProps, returns as elicitReturns } from "./Elicit.ts"; +import Elicit, { + generated as elicitGenerated, + props as elicitProps, + returns as elicitReturns, +} from "./Elicit.ts"; import TempDir, { props as tempDirProps } from "./TempDir.ts"; import { form as failForm, props as failProps } from "./Fail.ts"; -import Fetch, { props as fetchProps } from "./Fetch.ts"; -import Glob, { props as globProps, returns as globReturns } from "./Glob.ts"; +import Fetch, { generated as fetchGenerated, props as fetchProps } from "./Fetch.ts"; +import Glob, { + generated as globGenerated, + props as globProps, + returns as globReturns, +} from "./Glob.ts"; import Json, { props as jsonProps } from "./Json.ts"; import Parse, { props as parseProps, returns as parseReturns } from "./Parse.ts"; import SafeParse, { props as safeParseProps, returns as safeParseReturns } from "./SafeParse.ts"; @@ -171,9 +179,7 @@ export const CORE_REGISTRY: ComponentRegistry = new Map([ }, { returns: parseJsonObject(elicitReturns), - generated: { - paired: { identity: "https://executable.md/components/elicit/ask", effect: "write" }, - }, + generated: elicitGenerated, }, ), core("TempDir", TempDir, parseJsonObject(tempDirProps), { @@ -207,9 +213,7 @@ export const CORE_REGISTRY: ComponentRegistry = new Map([ context: null, }, { - generated: { - "self-closing": { identity: "https://executable.md/components/fetch/read", effect: "read" }, - }, + generated: fetchGenerated, }, ), core( @@ -226,9 +230,7 @@ export const CORE_REGISTRY: ComponentRegistry = new Map([ }, { returns: parseJsonObject(globReturns), - generated: { - "self-closing": { identity: "https://executable.md/components/glob/read", effect: "read" }, - }, + generated: globGenerated, }, ), core("CodeBlock", codeBlockForm, parseJsonObject(codeBlockProps), { diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index b77179ad4..abb63e3e8 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -135,6 +135,7 @@ import type { } from "./execution-declarations.ts"; import { documentationOf } from "./components/documentation.ts"; import { registerComponents } from "./components/registration.ts"; +import { admitGeneratedForms } from "./components/generated-metadata.ts"; import { admittedGenerated, declaredForms, @@ -714,13 +715,25 @@ function* materializeImport( : parseJsonObject(propsExport); yield* compilePropsSchema(props); + const forms = declaration === undefined ? undefined : declaredForms(declaration); + // What this component is offered generated use for, declared where it is + // implemented. A module that states none is a component no selection can + // admit, which is what a host's selection of the name then refuses on; one + // that states something unusable refuses here, where it is read, rather + // than at the first table that would admit the name. + const offered = "generated" in mod ? admitGeneratedForms(mod.generated, forms) : undefined; + if (offered !== undefined && !offered.ok) { + throw new Error(`Function component "${name}" at ${path} ${offered.error.message}`); + } + const definition: FunctionComponentDefinition = { kind: "function", name, props, // Read off the same declaration the dispatcher is built from, so what the // component accepts and what it says it accepts come from one value. - ...(declaration === undefined ? {} : { forms: declaredForms(declaration) }), + ...(forms === undefined ? {} : { forms }), + ...(offered === undefined ? {} : { generated: offered.value }), fn: declaration === undefined ? defaultExport : formDispatcher(declaration), }; diff --git a/packages/core/tests/generated-selection.test.ts b/packages/core/tests/generated-selection.test.ts index 37da7a0a2..e788cf515 100644 --- a/packages/core/tests/generated-selection.test.ts +++ b/packages/core/tests/generated-selection.test.ts @@ -145,6 +145,51 @@ function useProducer(produced: string[]): Operation { ]); } +/** + * A directory holding two repository components, reachable only by search path. + * + * Written as JavaScript generators rather than through a registration, because + * the point is the other way a component declaration reaches an execution: a + * file on the search path, imported by the loader, with whatever it exports. + */ +function useRepositoryComponents(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-u5-repository-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + // Offers its self-closing form, where it is implemented. + yield* writeTextFile( + join(dir, "Offered.ts"), + [ + 'export const props = { type: "object", properties: {}, additionalProperties: false };', + "export const generated = {", + ' "self-closing": {', + ' identity: "https://executable.md/components/offered/read",', + ' effect: "read",', + " },", + "};", + "export default function* Offered() {", + ' return "THE REPOSITORY COPY RAN";', + "}", + "", + ].join("\n"), + ); + // The same component, stating nothing about generated use. + yield* writeTextFile( + join(dir, "Silent.ts"), + [ + 'export const props = { type: "object", properties: {}, additionalProperties: false };', + "export default function* Silent() {", + ' return "THE SILENT COPY RAN";', + "}", + "", + ].join("\n"), + ); + yield* provide(dir); + }); +} + /** A directory holding one Markdown component, reachable only by search path. */ function useComponentDirectory(): Operation { return resource(function* (provide) { @@ -545,6 +590,44 @@ describe("Tier GS — what a selected name resolves to", () => { expect(attempt.output).toContain("the provider's answer ran"); }); + it("U5-3: a repository component is offered for what it declares, and no more", function* () { + const dir = yield* useRepositoryComponents(); + + // A file on the search path is a declaration like any other: what its + // self-closing form is offered for is on it, so a host that selects the + // name is offered exactly that and the fragment runs that body. + const offered = yield* run(evaluating(`\n`), { + includes: [dir], + installations: [ + { + *install(): Operation { + yield* useSelection({ Offered: ["self-closing"] }); + }, + }, + ], + }); + + expect(offered.failure).toBe(undefined); + expect(offered.output).toContain("THE REPOSITORY COPY RAN"); + + // The same shape, stating nothing: a component that declares no generated + // use is one no selection admits, and the host's statement cannot supply + // what the owner withheld. + const silent = yield* run(evaluating(`\n`), { + includes: [dir], + installations: [ + { + *install(): Operation { + yield* useSelection({ Silent: ["self-closing"] }); + }, + }, + ], + }); + + expect(silent.failure).toContain("offers no generated use"); + expect(silent.output).toBe(undefined); + }); + it("U5-3: an unpublished replacement refuses", function* () { const ran: string[] = []; const replacement: FunctionComponentDefinition = { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e4cd7dae8..7ef11a24c 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2414,6 +2414,19 @@ export interface FunctionComponentDefinition { } ``` +**Generated use.** A function component may export `generated`, the same +per-form map a registration declares (§5.3.2): each authored form it offers an +untrusted fragment, with the absolute `https://executable.md/` identity that +form runs under and whether it reads or writes. It is read where the file is +loaded and held to the forms the component accepts, so a component is one +declaration whichever way an execution reached it — core's own `Glob.ts` states +it here, and the registration reads the same value. A component that exports +none is offered for no generated use at all, and a host that selects its name is +refused rather than given a form nobody declared. Exporting it is not +permission: a fragment still needs the host's selection, the authored form, the +`allow` class and whole-fragment admission, and every effect still crosses the +installed providers and the effective `Permissions`. + **Resources.** A component acquires resources with ordinary operations — `yield* useTempDir()` — and needs no wrapper of its own. They belong to the invocation and are released when it completes, after the content it projected