From 5f3553cc8aaec5a2e4ebc2fd054d10f1afb9e794 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 14 Oct 2026 20:11:53 -0400 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Let=20a=20component=20say=20wha?= =?UTF-8?q?t=20its=20own=20spellings=20are=20offered=20as=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generated fragment names a component, and two facts decide what naming it grants: the identity a continuation is compared against, and whether the spelling observes or mutates. Both were stated beside the definition, by whichever host assembled the table, so one component's two spellings were two host-stated grants and a host could describe an effect the implementation does not have. Both now travel on the declaration that owns the implementation, per authored spelling: `` is offered under `https://executable.md/components/file/read` as a read and `…` under `.../file/write` as a write, on one definition. A provider publishes that definition through its existing request — `answer(definition)` in place of `claim(answer, identity)` — and the admitting tables read each selected spelling's identity and effect off the definition they already hold, refusing a spelling its owner offers no generated use of or offers as the other effect. The retained admission holds one row per admitted spelling: the name, the spelling, that URL, what kind of thing answered for the name, and the effect table that admitted it. There is one closed record shape, marked as itself; a record an earlier build wrote is refused as unreadable history before the admission it would have restored performs anything, and an updated implementation under the same URL is no longer refused for lacking a revision. Hosts keep deciding which names and spellings a run admits and stop restating what admitting them grants: the Git Plugin's `` entry carries no identity or compatibility constant, and `FragmentIdentity`, `CORE_REVISION`, the version-1 alias lists and the structural answer identity are gone. --- architecture.md | 98 +- packages/cli/src/evaluation-profile.ts | 8 +- packages/cli/src/workflow.ts | 5 +- packages/cli/tests/evaluate-workflow.test.ts | 18 +- .../cli/tests/plan-command-document.test.ts | 16 +- packages/cli/tests/plan-component.test.ts | 12 +- packages/core/host.ts | 3 +- packages/core/mod.ts | 7 + packages/core/src/components/Evaluate.ts | 39 +- packages/core/src/components/Syntax.ts | 7 + .../src/components/component-resolution.ts | 279 ++-- .../core/src/components/fixed-filesystem.ts | 76 +- .../core/src/components/generated-metadata.ts | 203 +++ packages/core/src/components/registration.ts | 20 +- packages/core/src/components/registry.ts | 58 +- packages/core/src/evaluation-profile.ts | 421 ++---- packages/core/src/execute.ts | 126 +- packages/core/src/generated-xmd.ts | 836 +++++------ packages/core/src/invocation-identity.ts | 16 + packages/core/src/syntax-admitted.ts | 22 +- packages/core/src/types.ts | 12 + packages/core/tests/all.test.ts | 11 +- packages/core/tests/answer-identity.test.ts | 417 +++--- .../core/tests/evaluate-component.test.ts | 301 ++-- .../tests/evaluate-provider-lifetime.test.ts | 72 +- .../core/tests/evaluation-profile.test.ts | 249 +-- .../core/tests/generated-composition.test.ts | 13 +- .../tests/generated-owner-metadata.test.ts | 389 +++++ packages/core/tests/generated-xmd.test.ts | 1333 +++++++---------- .../shared-operation-permissions.test.ts | 6 +- .../core/tests/support/answer-provider.ts | 121 +- packages/core/tests/syntax-component.test.ts | 40 +- .../core/tests/syntax-loaded-copy.test.ts | 18 +- packages/git/src/composition/definitions.ts | 34 +- .../workflow/src/deno/workspace/evaluate.ts | 10 +- .../workflow/src/generated-observations.ts | 14 +- .../tests/generated-agent-component.test.ts | 113 +- .../tests/generated-observations.test.ts | 4 +- specs/executable-mdx-spec.md | 129 +- specs/workflow-workspace-spec.md | 94 +- 40 files changed, 2888 insertions(+), 2762 deletions(-) create mode 100644 packages/core/src/components/generated-metadata.ts create mode 100644 packages/core/tests/generated-owner-metadata.test.ts diff --git a/architecture.md b/architecture.md index 72bada740..2f2d5683f 100644 --- a/architecture.md +++ b/architecture.md @@ -3433,12 +3433,12 @@ filesystem for it to hand. What a fragment may *do* is then the ordinary question asked of any caller: the provider that is installed, and the policy of the scope the element runs in. -The narrowing these revisions record is the one that still stands — an entry -authorizes one component under one spelling — so core's entries state revision 2 -and the bundled Git Plugin's `` states revision 3. **A run suspended under -the earlier revisions refuses to resume rather than silently receiving the -narrower grant**, which is what a revision is for: a continuation resumes only -under the exact identities it was admitted with. +The narrowing these changes record is the one that still stands — an entry +authorizes one component under one spelling, and what that spelling runs as is +the owning declaration's to state. **A run suspended under an earlier record +shape refuses to resume rather than silently receiving a grant nobody compared**: +a continuation resumes only under the exact identities and spellings it was +admitted with. `` is one Agent turn. The host hides no Agent loop inside it. Ordinary authored Markdown owns iteration with ``, branches on whether a reply @@ -3590,20 +3590,17 @@ reached when the two disagree. That check reads the engine's own account of the element — not `Component.hasContent()`, not the method on the object the component was handed, and not the trusted wrapper the evaluator answers the import with, which collects what a read returned and is not the form dispatcher. So -an admitted `File:read` does not become a write and an admitted `File:write` -does not become a read, however many times something is asked. +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 `File:write`, the bundled Git Plugin's paired -`Dir` under the versioned origin -`@executablemd/workflow/composition/dir-v2#Dir` — built from the same -implementation canonical core owns so the grant and the authored component -cannot drift — and core's self-closing `@executablemd/core#File.Delete`. That origin -names where released history recorded the entry, not what owns it today: -canonical core owns ``, the Git Plugin states the identity its released -runs retained, and the string stays unchanged so those runs still replay. A -host's own -extensions come after them. Selecting `write` intentionally authorizes Dir's +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 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 @@ -3656,31 +3653,29 @@ 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 selected -pinned identities with their admitted forms, and the normalized request -ceilings — compares whole and exactly, in canonical order. A pinned identity is -one closed tagged record — whether canonical core supplies the body or the -ordinary import chain answered for it, the origin, the component key and the -revision — rather than an assembled spelling, so a reader comparing two -admissions can say which term moved and no two hosts have to agree on a -separator. The untagged version-1 record holds one opaque string instead — -whatever the party that built the pinned entry chose, which for core was -`@executablemd/core#File:read` and for the workflow host was -`@executablemd/workflow/composition/dir-v2#Dir`, with no revision in either and -no convention to read back out. Nothing recovers four structural terms from one -of those, so nothing tries: a *capability* entry states the exact version-1 -strings it succeeds, and a retained string reconciles against that stated list -and against nothing else. Stating one is an assertion by whoever owns the entry -that the current grant is no wider than the old one; an entry that states none -refuses every version-1 record naming it. A component answer states no alias and -has nowhere to put one, because that arm did not exist when version-1 records -were written. New records always state the structural identity. The run's own -ceiling table is keyed by those terms encoded rather than joined, because a -joined key is only as unique as the separator is illegal and nothing makes a -space illegal in an origin or a component key: two entries whose terms differ -only in where a space falls 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 under. Deciding the +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 +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 +effect table admitted it. So a reader comparing two admissions can say which +spelling of which name moved, and no host has to agree with another about how +to spell anything — the identity is stated by the declaration that owns the +implementation, where `` reads under +`https://executable.md/components/file/read` and `…` writes under +`.../file/write`. There is one such record shape, marked as itself: a record +an earlier build wrote — the untagged #369 one, or the tagged one whose +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 +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 what expands is the source this run admitted rather than whatever a later caller @@ -4670,13 +4665,16 @@ Canonical answer windows are owned the same way and by the same kind of value an explicit object rather than a scope — but serially: component answers are resolved in one capture phase before any document expands, so one window is open at a time. Each provider request captures that window object before its handler -runs, a registration states it back explicitly, and a handler delegating through a -descendant scope answers the resolution it was opened in. One installation may +runs, a publication presents it back explicitly, and a handler delegating through +a descendant scope answers the resolution it was opened in. A provider publishes +the definition and nothing else: which identity and effect each of its spellings +runs as is on that definition, declared by whoever owns the implementation, so +there is nothing beside it for a reader to reconcile. One installation may answer more than one import with the same definition — a reusable provider owns -one object and a registration is per window — and a second, *different* answer in +one object and a publication is per window — and a second, *different* answer in either still refuses. Concurrent document imports happen after capture, with no window open at all: such an import may be answered as ordinary middleware, and -acquires no profile identity. +acquires no admitted identity. The engine then mints one issuance per invocation, carrying that domain — or none — the authored name for what a refusal says, and the frame the body is @@ -5568,9 +5566,9 @@ imports still require their own settled selection frame; a routed function cannot repair a missing or multiply selected domain. Paired producers retain their lexical site route and operational permission. -A trusted provider may therefore delegate and register canonical self-closing -`` as a component answer. Its default bare form reports the admitted -vocabulary; its named form renders the enclosing authoring documentation with +A trusted provider may therefore delegate canonical self-closing ``, +which canonical core publishes for itself, and have it admitted as a component +answer. Its default bare form reports the admitted vocabulary; its named form renders the enclosing authoring documentation with narrowed availability. Verified protected documentation provenance survives sealing even when the delegating provider has a different origin. That provenance is descriptive data and never a callable lookup key. diff --git a/packages/cli/src/evaluation-profile.ts b/packages/cli/src/evaluation-profile.ts index fe96a11cf..cb33bc8ec 100644 --- a/packages/cli/src/evaluation-profile.ts +++ b/packages/cli/src/evaluation-profile.ts @@ -38,11 +38,11 @@ * * `` 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 identity with an empty ceiling would be a different thing from - * not admitting it — the fragment would name a component it can never - * successfully call. + * 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. * - * ## No Workspace, and no alias + * ## No Workspace * * An ordinary run evaluates against no Workspace. Its admitted effects address * the Files provider its own execution installed, and there is no immutable diff --git a/packages/cli/src/workflow.ts b/packages/cli/src/workflow.ts index 70cff4380..220057763 100644 --- a/packages/cli/src/workflow.ts +++ b/packages/cli/src/workflow.ts @@ -1076,8 +1076,9 @@ export function runWorkflow( // 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 package that owns the component, so - // the write table names one identity rather than two copies of it. + // ``'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 ? [] : [ diff --git a/packages/cli/tests/evaluate-workflow.test.ts b/packages/cli/tests/evaluate-workflow.test.ts index 061d2747d..1bbb35d29 100644 --- a/packages/cli/tests/evaluate-workflow.test.ts +++ b/packages/cli/tests/evaluate-workflow.test.ts @@ -106,19 +106,15 @@ describe("Tier FE — the workflow host's profile", () => { }); }); - it("FE23: Git's directory entry is the exact identity retained history holds", function* () { - // Written out here rather than compared against the implementation, so a - // change to either side is a change to this row. Every released journal - // holds these strings: the origin a released build recorded, the revision - // it recorded, and the version-1 alias whose grant this entry answers for. + it("FE23: Git's directory entry admits core's own Dir 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 + // 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(entry.identity).toEqual({ - origin: "@executablemd/workflow/composition", - key: "Dir", - revision: "3", - }); - expect(Reflect.get(entry, "legacy")).toEqual(["@executablemd/workflow/composition/dir-v2#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"); }); diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index 0f182934a..1806effad 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -1184,12 +1184,7 @@ describe("Tier PI — read-only information requests", () => { /** The admitted entry the barrier is written as. */ function heldEntry(): FragmentEntry { - return { - kind: "component-answer", - name: "Held", - identity: { origin: HELD_ORIGIN, key: "Held", revision: "1" }, - forms: ["self-closing"], - }; + return { kind: "component-answer", name: "Held", forms: ["self-closing"] }; } /** One barrier: a body whose cleanup blocks until the case releases it. */ @@ -1209,6 +1204,13 @@ describe("Tier PI — read-only information requests", () => { kind: "function", name: "Held", props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: { + "self-closing": { + identity: "https://executable.md/components/held/read", + effect: "read", + }, + }, *fn(): Operation { // Registered before anything can fail, so the barrier is reached // whichever way this projection ends. @@ -1219,7 +1221,7 @@ describe("Tier PI — read-only information requests", () => { return "held"; }, }, - { origin: HELD_ORIGIN, key: "Held", revision: "1" }, + { origin: HELD_ORIGIN }, ), }; } diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index d318b645b..3e7d1645f 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -2027,9 +2027,9 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { * The profile these rows evaluate under: the read entry, and never the wide * one. * - * The entry's identity is the fixture's own, so the catalog an agent is shown - * inside an evaluation names `test://fe713/read` and the admitted capability - * is core's file read behind it. + * 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. */ function fixtureProfile(): FragmentEvaluationInput { return { @@ -2037,7 +2037,6 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { { ...fileReadEntry(), name: READ_COMPONENT.name, - identity: { origin: READ_COMPONENT.origin, key: "FE713.Read", revision: "1" }, description: READ_COMPONENT.description, }, ], @@ -2163,7 +2162,10 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { expect(seen).toHaveLength(1); const catalog = seen[0] ?? ""; expect(catalog).toContain(READ_MARKER); - expect(catalog).toContain(READ_COMPONENT.origin); + // Reported as the implementation it runs rather than as the name it was + // admitted under: the entry is core's own file read, which is what the + // host chose to admit under a name of its own. + expect(catalog).toContain("@executablemd/core"); // The wide-only component exists in this scope and is not in the profile, // so its absence here is the narrowing rather than the fixture. expect(catalog).not.toContain(WIDE_MARKER); diff --git a/packages/core/host.ts b/packages/core/host.ts index 422701678..df68282b6 100644 --- a/packages/core/host.ts +++ b/packages/core/host.ts @@ -97,7 +97,6 @@ export type { FragmentEntry, FragmentEvaluationInput, FragmentForm, - FragmentIdentity, FragmentWorkspaceAccess, } from "./src/evaluation-profile.ts"; /** @@ -329,11 +328,11 @@ export { export type { GeneratedComponentForm, GeneratedEffectClass, + GeneratedImplementation, GeneratedMutation, GeneratedObservation, GeneratedRequest, GeneratedXmdRequest, - RetainedFragmentIdentity, } from "./src/generated-xmd.ts"; /** diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 8e898209f..17778873f 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -286,6 +286,13 @@ export { registerComponents, } from "./src/components/registration.ts"; export type { ComponentRegistration } from "./src/components/registration.ts"; +// What a registration says about the generated use of its own forms. The two +// members a declaration states; nothing here reads or publishes a catalog of them. +export type { + GeneratedEffect, + GeneratedForm, + GeneratedForms, +} from "./src/components/generated-metadata.ts"; export { DEFAULT_INCLUDES, selectComponent } from "./src/components/select.ts"; export type { SelectOptions } from "./src/components/select.ts"; export { CORE_COMPONENT_NAMES } from "./src/components/registry.ts"; diff --git a/packages/core/src/components/Evaluate.ts b/packages/core/src/components/Evaluate.ts index d5b33e00c..bab37f9b9 100644 --- a/packages/core/src/components/Evaluate.ts +++ b/packages/core/src/components/Evaluate.ts @@ -79,10 +79,10 @@ import type { CapturedEntry, EvaluationProfile } from "../evaluation-profile.ts" import { evaluateProtectedGeneratedXmd } from "../generated-xmd.ts"; import type { GeneratedEffectClass, + GeneratedImplementation, GeneratedMutation, GeneratedObservation, GeneratedXmdRequest, - RetainedFragmentIdentity, } from "../generated-xmd.ts"; import { ComponentInvocationError, invocationForm } from "../invocation-identity.ts"; import type { @@ -322,13 +322,13 @@ interface SelectedTables { } /** - * The host's captured tables, as the pinned identities the evaluator admits. + * The host's captured tables, as the pinned entries the evaluator admits. * * A class the host installed nothing for is refused here — before the durable * name is assigned and before a program exists — rather than reaching the - * evaluator as an empty table. The identity a run retains is built from the - * three structural parts the host stated, so a reader comparing two admissions - * can say which part moved. + * evaluator as an empty table. The identity a run retains for each admitted + * spelling comes from the definition that spelling runs, where the declaration + * that owns the implementation states it. * * The definition carries the *captured* schema rather than its own, so a host * that edits the schema on the definition it handed over after installation @@ -352,18 +352,14 @@ function selectedTables( for (const entry of profile.read) { observations.push({ name: entry.name, - identity: pinned(entry), definition: entry.definition, + kind: implementation(entry), ...(entry.dispatch === undefined ? {} : { dispatch: entry.dispatch }), // An entry admitted for one spelling admits that one; an entry admitted // for both is the form-insensitive component it has always been. ...(entry.forms.length === 1 && entry.forms[0] === "self-closing" ? { selfClosing: true } : {}), - // The version-1 strings this entry stated it succeeds, carried through - // so a continuation admitted under one of them reconciles against this - // entry. Nothing this run writes holds them. - ...(entry.legacy === undefined ? {} : { legacy: entry.legacy }), ...(entry.requests === undefined ? {} : { requests: entry.requests.map(asRequest) }), }); admitted.push(entry); @@ -376,10 +372,9 @@ function selectedTables( for (const entry of profile.write) { mutations.push({ name: entry.name, - identity: pinned(entry), definition: entry.definition, + kind: implementation(entry), ...(entry.dispatch === undefined ? {} : { dispatch: entry.dispatch }), - ...(entry.legacy === undefined ? {} : { legacy: entry.legacy }), form: entry.forms.length === 2 ? "either" : (entry.forms[0] ?? "self-closing"), }); admitted.push(entry); @@ -389,20 +384,16 @@ function selectedTables( } /** - * The identity a run retains for one captured entry. + * What kind of thing is behind one captured entry's name. * - * Structural rather than assembled: the four terms travel as themselves, so a - * continuation compares them one at a time and a reader looking at two - * admissions can say which of them moved. Nothing here is derived from the - * implementation — an implementation is not an identity — and the kind travels - * with the rest because an operation core supplies the body for and an answer - * the import chain resolved are different grants under the same three names. + * 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. */ -function pinned(entry: CapturedEntry): RetainedFragmentIdentity { - const { origin, key, revision } = entry.identity; - return entry.kind === "component-answer" - ? { kind: "component-answer", origin, key, revision } - : { kind: "capability", origin, key, revision }; +function implementation(entry: CapturedEntry): GeneratedImplementation { + return entry.kind === "component-answer" ? "component-answer" : "capability"; } /** One captured ceiling, as the request record the evaluator compares against. */ diff --git a/packages/core/src/components/Syntax.ts b/packages/core/src/components/Syntax.ts index 6b4e47c07..2d4f1e0f9 100644 --- a/packages/core/src/components/Syntax.ts +++ b/packages/core/src/components/Syntax.ts @@ -118,6 +118,13 @@ export const SYNTAX_PROTECTED: ExecutionBuiltComponent = { origin: CORE_ORIGIN, props, forms: ["self-closing"], + // Offered to a generated fragment as a read, in the one spelling it has. The + // metadata travels to the definition each execution builds from this + // declaration, so an admitted `` is compared against the identity + // its owner states rather than one a host wrote beside the entry. + generated: { + "self-closing": { identity: "https://executable.md/components/syntax/read", effect: "read" }, + }, ...documented({ description: "Inspect available components and control-flow constructs. `` lists the " + diff --git a/packages/core/src/components/component-resolution.ts b/packages/core/src/components/component-resolution.ts index 7a439f84e..4554d6e3c 100644 --- a/packages/core/src/components/component-resolution.ts +++ b/packages/core/src/components/component-resolution.ts @@ -18,6 +18,7 @@ * nothing a handler still holds decides what is *invoked*. */ +import { definitionGeneratedRefusal } from "./generated-metadata.ts"; import type { ComponentDefinition, FunctionComponentDefinition, SourcePosition } from "../types.ts"; /** A definition an import may answer with. */ @@ -168,44 +169,31 @@ function read(inspect: () => T): T | undefined { * resembles one. */ /** - * A provider's stable statement about the implementation it supplied. - * - * `origin` is canonical execution's to fix, not the provider's: each provider - * installation carries its origin into every request it opens, so one provider - * cannot state an identity under another's name. - */ -export interface AnswerIdentity { - readonly origin: string; - readonly key: string; - readonly revision: string; -} - -/** What a provider states, minus the part it does not get to choose. */ -export interface ClaimedIdentity { - readonly key: string; - readonly revision: string; -} - -/** - * One identified answer: what a provider stated, and what core kept of it. + * One published answer: core's own copy, and which provider installation + * published it. * * The two travel together because a caller needs both and must not obtain them - * separately. The definition is core's own copy, taken when the identity was - * recorded; it is what the caller keeps, and it is why nothing downstream ever - * reads the object the public chain returned a second time. + * separately. The definition is core's copy, taken when it was published, and + * the origin is the publishing installation's — provenance a reader and a + * documentation symbol are shown, never a statement about what the + * implementation is. */ export interface IdentifiedAnswer { - readonly identity: AnswerIdentity; + readonly origin: string; readonly definition: ImportedDefinition; } /** - * What one answer object was identified as, and which resolutions identified + * What one published answer was published as, and which resolutions published * it. * - * One implementation says what it is once, so the name, the identity and the - * installation belong to the object: a new resolution cannot rename it, restate - * its origin or revision, or take it for another provider. + * One implementation says what it is once, so the name and the installation + * belong to the object: a new resolution cannot take it for another provider. + * + * What the implementation *is* is no longer stated beside it. The identity and + * the effect of each generated form are on the definition, declared by whoever + * owns the implementation, so a provider publishes the definition and states + * nothing a reader would have to reconcile with it. * * Which import it answered is a different question, and the answer to it is per * resolution — so the copies are keyed by the window *object*, not by a number @@ -221,33 +209,39 @@ export interface IdentifiedAnswer { * definition the second time, which is a provider being punished for not * copying itself. */ -interface Claim { +interface Answer { readonly name: string; - readonly identity: AnswerIdentity; - /** Which provider installation stated it, so a second cannot overwrite. */ + /** + * The installation origin that published it. + * + * Provenance and diagnostic information, which is all an origin has ever + * been: it says which provider installation spoke, and it is no part of what + * the implementation is — that is the definition's own metadata. + */ + readonly origin: string; + /** Which provider installation published it, so a second cannot overwrite. */ readonly installation: object; /** - * Core's copy of the definition the first identity statement described. + * Core's copy of the definition the first publication supplied. * - * The identity above is a statement about *this* definition, so a later - * identity statement of the same object has to be a statement about the same - * definition. Without this baseline, a provider could identify an object, - * edit it, and have the next window retain the edited version under the - * identity and revision that described the original — a different - * implementation wearing the first one's name. + * A publication is a statement about *this* definition, so a later + * publication of the same object has to supply the same definition. Without + * this baseline, a provider could publish an object, edit it, and have the + * next window retain the edited version — a different implementation, and + * different generated metadata, under the first one's publication. * - * Absent when the definition in the first identity statement could not be - * copied at all, which is not a state a later identity statement can - * improve: an answer core could not retain is not one it can start vouching - * for because the object has since changed. + * Absent when the definition in the first publication could not be copied at + * all, which is not a state a later publication can improve: an answer core + * could not retain is not one it can start vouching for because the object + * has since changed. */ readonly baseline: ImportedDefinition | undefined; /** - * One retained copy per resolution this answer was identified in. + * One retained copy per resolution this answer was published in. * * Window keys are weak: retaining an answer does not keep its past * resolutions alive. The outer record is weakly keyed by the answer, too. - * Presence is the provenance check: a window that never identified this + * Presence is the provenance check: a window that never published this * answer has no entry, and `identify` answers nothing for it however many * other windows did. Neither weak association promises collection timing. */ @@ -256,19 +250,19 @@ interface Claim { /** A provider request used after its execution ended. */ export const REVOKED_ANSWER_IDENTIFICATION = - "the execution that installed this answer provider has ended, so nothing it states identifies " + - "an implementation here"; + "the execution that installed this answer provider has ended, so nothing it publishes " + + "identifies an implementation here"; -/** An identity stated outside the handler invocation it would have been an answer to. */ -export const SETTLED_CLAIM = - "this resolution has settled, so a claim stated now identifies nothing. An answer is " + - "identified by the handler invocation the import asked, while that invocation is still " + +/** A definition published outside the handler invocation it would have answered. */ +export const SETTLED_ANSWER = + "this resolution has settled, so a definition published now identifies nothing. An answer is " + + "published by the handler invocation the import asked, while that invocation is still " + "deciding; a handler that has returned, or one recording after the fact, is not supplying it."; /** A second, different statement from one installation about one resolution. */ export const SPENT_OPPORTUNITY = - "this provider already stated which implementation answers this import, and one import is one " + - "implementation. The next resolution offers a fresh request; this one is decided."; + "this provider already published which implementation answers this import, and one import is " + + "one implementation. The next resolution offers a fresh request; this one is decided."; /** * One open resolution, and the only way to end it. @@ -284,32 +278,41 @@ export interface ResolutionWindow { readonly name: string; /** Which resolution this is, for a reader following two of them. */ readonly occurrence: number; - /** Stop accepting identity statements for this resolution. Idempotent. */ + /** Stop accepting publications for this resolution. Idempotent. */ close(): void; } /** - * What one handler invocation may state about the import it was asked. + * What one handler invocation may publish for the import it was asked. * * Minted per invocation, and closed the moment that invocation returns, throws - * or is cancelled. This is what a provider states an identity through: the + * or is cancelled. This is what a provider publishes a definition through: the * installation gives a provider the right to *be asked*, and the request gives * it the right to answer this one asking. Separating them is what makes a - * statement provable — a stable installation handle can only say "some + * publication provable — a stable installation handle can only say "some * provider", while a request says "this handler, deciding this import, right * now". * - * There is no `name` parameter on `claim`. The name is fixed when the request - * is minted, from what the chain asked, so a handler cannot state an answer for - * a component it was not asked about. + * `answer` takes the definition and nothing else. The name is fixed when the + * request is minted, from what the chain asked, so a handler cannot answer for + * a component it was not asked about; and which identity and effect each + * generated form runs as is the definition's own, declared by whoever owns the + * implementation. A provider that had to state those separately could describe + * an implementation it did not supply. */ export interface ComponentAnswerRequest { /** The component this invocation was asked to resolve. */ readonly name: string; /** Where the element that asked was written, when the chain knew. */ readonly position?: Readonly; - /** State which implementation answers this import, as this provider. */ - claim(answer: ImportedDefinition, identity: ClaimedIdentity): ImportedDefinition; + /** + * Publish the definition that answers this import, as this provider. + * + * Synchronous, and it performs nothing: no component body runs, no I/O + * happens, and the definition's own generated metadata is read and validated + * off core's copy of it rather than off the object the chain still holds. + */ + answer(definition: ImportedDefinition): ImportedDefinition; } /** One minted request, and the caller's own handle for ending it. */ @@ -330,7 +333,7 @@ export interface OpenAnswerRequest { * One provider installation's private right to be asked. * * Holding this is the right to be asked, fixed to one origin. It is not the - * right to answer: every statement goes through a request this mints for one + * right to answer: every publication goes through a request this mints for one * invocation, so a retained installation handle can open a *new* request but * cannot revive a settled one or reach another provider's. */ @@ -346,27 +349,22 @@ export interface ProviderInstallation { open(name: string, position?: Readonly): OpenAnswerRequest; } -/** An identity a provider stated in a shape this execution cannot record. */ +/** A definition a provider published where this execution cannot record one. */ export class AnswerIdentityError extends Error { override name = "AnswerIdentityError"; } -/** The retained spelling of one identity, for a reader. */ -export function identityRecord(identity: AnswerIdentity): string { - return `${identity.origin}#${identity.key}@${identity.revision}`; -} - export class CanonicalImports { readonly #issued = new WeakMap(); /** - * The identities providers stated, in the same owner that holds issuance. + * The definitions providers published, in the same owner that holds issuance. * * One owner rather than two registries: retention, exact-object lookup, the * `stillDescribes` comparison and the lifecycle are one question asked about * one table, and a parallel WeakMap would be a second place for an answer to * be verified from. */ - readonly #claims = new WeakMap(); + readonly #answers = new WeakMap(); /** * Whether this owner identifies anything yet. * @@ -502,13 +500,12 @@ export class CanonicalImports { // would let a provider edit what a *later* reader of that element // sees — a diagnostic seam turned into a write. ...(position === undefined ? {} : { position: capturePosition(position) }), - claim(answer: ImportedDefinition, stated: ClaimedIdentity): ImportedDefinition { + answer(definition: ImportedDefinition): ImportedDefinition { return owner.#record( installation, origin, { name, window: opened, live: () => live }, - answer, - stated, + definition, ); }, }); @@ -531,38 +528,32 @@ export class CanonicalImports { live: () => boolean; }, answer: ImportedDefinition, - stated: ClaimedIdentity, ): ImportedDefinition { if (!this.#active) { throw new AnswerIdentityError(REVOKED_ANSWER_IDENTIFICATION); } - // Four questions, and each of them is about the invocation rather than + // Three questions, and each of them is about the invocation rather than // about the provider: is this handler still deciding; is the resolution it // was asked in the one still open, by identity; and is the name it was asked // the name that resolution is deciding. A stable installation handle answers - // none of them, which is why it does not claim. + // none of them, which is why it does not publish. const open = asked.window; if (!asked.live() || open === undefined || open !== this.#window || open.name !== asked.name) { - throw new AnswerIdentityError(SETTLED_CLAIM); + throw new AnswerIdentityError(SETTLED_ANSWER); } - const identity = complete(origin, stated); const held = - typeof answer === "object" && answer !== null ? this.#claims.get(answer) : undefined; + typeof answer === "object" && answer !== null ? this.#answers.get(answer) : undefined; if (held !== undefined) { - // The same installation may restate this object's identity for another - // import or repeat it within one resolution. A different installation - // cannot restate it, even identically: the first provider's statement - // stands, so a second cannot rename the first's implementation. - if ( - held.installation !== installation || - held.name !== asked.name || - held.identity.origin !== identity.origin || - held.identity.key !== identity.key || - held.identity.revision !== identity.revision - ) { + // What this object answers for was settled by its first publication. + // Publishing it again is what a provider installed twice does, and what a + // reusable one does for every import it answers; anything else is two + // providers disagreeing about one object, and the first publication + // stands — a later one that overwrote it would let a second provider take + // the first's implementation. + if (held.installation !== installation || held.name !== asked.name) { throw new AnswerIdentityError( - "this answer already carries an identity, and a second claim does not replace it. One " + - "implementation states what it is once.", + `this answer was already published by "${held.origin}" for "${held.name}", and a ` + + "second publication does not replace it. One implementation is published once.", ); } if (held.copies.has(open)) { @@ -582,9 +573,9 @@ export class CanonicalImports { !stillDescribes(held.baseline, copy) ) { throw new AnswerIdentityError( - "this answer no longer describes the implementation its identity was stated about, so a " + - "further import cannot be answered with it. One implementation states what it is " + - "once, and stays that.", + "this answer no longer describes the implementation that was published, so a further " + + "import cannot be answered with it. One implementation is published once — its " + + "function, its schema and the generated metadata on it — and stays that.", ); } // The same definition answering a different import. That is one provider @@ -597,20 +588,33 @@ export class CanonicalImports { this.#spend(installation, open); return answer; } - // Secondary, and about the provider rather than the invocation: naming a + // Secondary, and about the provider rather than the invocation: publishing a // *different* implementation for an import this provider already answered // is two answers where only one could be what it resolved to. if (this.#spent.get(installation)?.has(open) === true) { throw new AnswerIdentityError(SPENT_OPPORTUNITY); } - // Copied on the way in, so a later edit of the claimed object is visible as - // the change it is. + // Copied on the way in, so a later edit of the published object is visible + // as the change it is. const baseline = retain(answer); + // Read off core's own copy rather than off the object the chain still + // holds: a definition whose metadata answers one way for a check and + // another way for the read would otherwise be validated as one thing and + // retained as another. An answer that could not be copied at all is left + // for the comparison below to fail closed on, because there is nothing + // here to validate. + const refusal = baseline === undefined ? undefined : definitionGeneratedRefusal(baseline); + if (refusal !== undefined) { + throw new AnswerIdentityError( + `this answer ${refusal}. A generated form is offered under an absolute URL and one ` + + "effect, by the declaration that owns the implementation.", + ); + } const copies = new WeakMap(); copies.set(open, baseline); - this.#claims.set(answer, { + this.#answers.set(answer, { name: asked.name, - identity, + origin, installation, baseline, copies, @@ -637,48 +641,51 @@ export class CanonicalImports { } /** - * The identity stated for this exact answer, and core's own copy of it. + * Core's own copy of the definition a provider published for this exact + * answer and the installation that published it, or nothing when none did. * * Read after the whole public chain has returned, so what is asked about is * the final answer rather than an intermediate one. Nothing here refuses: an - * unidentified answer is an ordinary answer, and whether that is enough is - * the caller's question. + * answer nobody published is an ordinary answer, and whether that is enough + * is the caller's question. * - * Both halves come back together, and the copy is the one taken when the - * identity was recorded rather than one made now. Answering with the - * identity alone would leave the caller holding the chain's object and - * needing to copy it itself — one more read of a value the chain controls, - * after the read this one checked. An alternating proxy or an accessor that - * answers twice would pass the check and hand the second answer to the copy. - * So the check and the thing kept are one result of one call, and the object - * that travelled through the chain is never read again. + * The copy is the one taken when the definition was published rather than one + * made now, and it is what comes back. Answering with anything less would + * leave the caller holding the chain's object and needing to copy it itself — + * one more read of a value the chain controls, after the read this one + * checked. An alternating proxy or an accessor that answers twice would pass + * the check and hand the second answer to the copy. So the check and the + * thing kept are one result of one call, and the object that travelled + * through the chain is never read again. Which identity and effect each of + * its forms is offered under is on the copy, because it is the definition's + * own. */ identify(resolution: ResolutionWindow, answer: unknown): IdentifiedAnswer | undefined { if (!this.#active || typeof answer !== "object" || answer === null) { return undefined; } - const claim = this.#claims.get(answer); - if (claim === undefined) { + const published = this.#answers.get(answer); + if (published === undefined) { return undefined; } // The caller presents the resolution it opened, and this answer has to have - // been claimed in *that* one, under the name it decides. Nothing here + // been published in *that* one, under the name it decides. Nothing here // consults the owner's current window: provenance read out of mutable state - // would be a claim about whenever the question was asked rather than about - // which import the statement answered. A window that never claimed this - // answer holds no copy of it, whatever its siblings did. - if (claim.name !== resolution.name || !claim.copies.has(resolution)) { + // would describe whenever the question was asked rather than which import + // the publication answered. A window that never published this answer holds + // no copy of it, whatever its siblings did. + if (published.name !== resolution.name || !published.copies.has(resolution)) { return undefined; } - // This window's own copy, taken when it claimed. A claimed object the chain - // went on to edit is not the thing that was claimed: reading it runs - // whatever it is made of, and a value that refuses to be compared has + // This window's own copy, taken when it was published. A published object + // the chain went on to edit is not the thing that was published: reading it + // runs whatever it is made of, and a value that refuses to be compared has // failed the comparison. - const canonical = claim.copies.get(resolution); + const canonical = published.copies.get(resolution); if (canonical === undefined || !stillDescribes(canonical, answer)) { return undefined; } - return Object.freeze({ identity: claim.identity, definition: canonical }); + return Object.freeze({ origin: published.origin, definition: canonical }); } /** @@ -759,32 +766,6 @@ function capturePosition(position: Readonly): Readonly` reads the content; ' + - '`…` writes the content. The path is relative to the ' + - "working directory.", - as: "Optional. Captures the file's text instead of emitting it, in the reading form.", - context: "The text to write, in the writing form.", - }), - fixed("File.Delete", fileDeleteForm, parseJsonObject(fileDeleteProps), { - description: - 'Delete a file. `` removes it. It does not error if the ' + - "file doesn't exist, and produces no output.", - as: "Optional. Captures the empty string this renders, as for any text component.", - context: null, - }), - fixed("Dir", dirForm, parseJsonObject(dirProps), { - description: - 'Create or use a directory. `…` changes the working directory ' + - "for its content.", - as: null, - context: "The Markdown expanded in that directory.", - }), + fixed( + "File", + fileForm, + parseJsonObject(fileProps), + { + description: + 'Read or write a file. `` reads the content; ' + + '`…` writes the content. The path is relative to the ' + + "working directory.", + as: "Optional. Captures the file's text instead of emitting it, in the reading form.", + context: "The text to write, in the writing form.", + }, + { + // The two spellings do different things, so each one is offered under its + // own identity and effect: a table admitting the reading form admits no + // write, and a continuation compares the URL the spelling actually ran as. + "self-closing": { identity: "https://executable.md/components/file/read", effect: "read" }, + paired: { identity: "https://executable.md/components/file/write", effect: "write" }, + }, + ), + fixed( + "File.Delete", + fileDeleteForm, + parseJsonObject(fileDeleteProps), + { + description: + 'Delete a file. `` removes it. It does not error if the ' + + "file doesn't exist, and produces no output.", + as: "Optional. Captures the empty string this renders, as for any text component.", + context: null, + }, + { + "self-closing": { identity: "https://executable.md/components/file/delete", effect: "write" }, + }, + ), + fixed( + "Dir", + dirForm, + parseJsonObject(dirProps), + { + description: + 'Create or use a directory. `…` changes the working directory ' + + "for its content.", + as: null, + context: "The Markdown expanded in that directory.", + }, + { + paired: { identity: "https://executable.md/components/dir/ensure", effect: "write" }, + }, + ), ]); const BY_NAME: ReadonlyMap = new Map( diff --git a/packages/core/src/components/generated-metadata.ts b/packages/core/src/components/generated-metadata.ts new file mode 100644 index 000000000..d3e140502 --- /dev/null +++ b/packages/core/src/components/generated-metadata.ts @@ -0,0 +1,203 @@ +/** + * What a declaration says about the generated use of its own forms (spec §5.3). + * + * A generated fragment names a component, and what that name is admitted as — + * which URL a continuation is compared against, and whether the form observes + * or mutates — belongs to the declaration that owns the implementation. It used + * to be stated beside the definition, by whichever host assembled the table, so + * one component's two spellings were two host-stated grants and a host could + * describe an effect the implementation does not have. + * + * So the metadata travels on the declaration, per authored form, and every + * table that admits a name reads it off the definition it already holds. A + * component that offers no generated use carries none, which is the ordinary + * case: metadata is what makes a form offerable, and offering none takes + * nothing away from authored use. + * + * ## Validated, not trusted + * + * A declaration crosses a package boundary, so this reads it the way durable + * input is read: exactly the two members, an identity in the accepted HTTPS + * namespace, one of the two effects, and a form the declaration itself accepts. + * The identity is stored and compared as the supplied string — nothing fetches + * it, normalizes it into an alias, or infers it from the implementation. + * + * It imports no registry and no protected table. Validating plain metadata + * through the component graph would reopen the initialization cycle that graph + * already exposes, and nothing here needs to know which components exist. + */ + +import type { InvocationForm } from "../invocation-identity.ts"; + +/** Whether a generated form observes or mutates. */ +export type GeneratedEffect = "read" | "write"; + +/** What one authored form is offered for generated use as. */ +export interface GeneratedForm { + /** The absolute URL a continuation is compared against. */ + readonly identity: string; + readonly effect: GeneratedEffect; +} + +/** The generated use a declaration offers, by authored form. */ +export type GeneratedForms = Readonly>>; + +/** The namespace every generated identity is written in. */ +export const GENERATED_IDENTITY_ORIGIN = "https://executable.md"; + +const EFFECTS: readonly GeneratedEffect[] = ["read", "write"]; +const FORMS: readonly InvocationForm[] = ["self-closing", "paired"]; + +/** The authored form this value names, or nothing when it names none. */ +function authoredForm(value: unknown): InvocationForm | undefined { + return FORMS.find((form) => form === value); +} + +/** The effect this value names, or nothing when it names none. */ +function generatedEffect(value: unknown): GeneratedEffect | undefined { + return EFFECTS.find((effect) => effect === value); +} + +/** + * Why this metadata is unusable, or nothing when it is usable. + * + * A sentence rather than a thrown error, like `formsRefusal()`: a registration, + * a factory declaration and a resolved provider definition each refuse in their + * own error family and with their own diagnostic context, and a shared throw + * here would take that context away from all three. + * + * `accepted` is the forms the declaration itself accepts — `undefined` keeping + * its ordinary meaning of both — because offering generated use of a form the + * component refuses to be written in is offering nothing that can run. + */ +export function generatedRefusal( + generated: unknown, + accepted: readonly InvocationForm[] | undefined, +): string | undefined { + if (generated === undefined) { + return undefined; + } + if (typeof generated !== "object" || generated === null || Array.isArray(generated)) { + return "states generated forms that are not a map of authored forms"; + } + const offerable = accepted === undefined ? FORMS : accepted; + for (const key of Reflect.ownKeys(generated)) { + const form = authoredForm(key); + if (form === undefined) { + return `states generated metadata for "${String(key)}", which is not an authored form`; + } + if (!offerable.includes(form)) { + return `offers generated use of its ${form} form, which it does not accept`; + } + const refusal = formRefusal(Reflect.get(generated, form), form); + if (refusal !== undefined) { + return refusal; + } + } + return undefined; +} + +function formRefusal(value: unknown, form: InvocationForm): string | undefined { + const malformed = `states a ${form} generated form that is not an identity and an effect`; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return malformed; + } + const members = Reflect.ownKeys(value); + if (members.length !== 2 || !members.includes("identity") || !members.includes("effect")) { + return malformed; + } + if (generatedEffect(Reflect.get(value, "effect")) === undefined) { + return `offers its ${form} form under an effect this engine does not have`; + } + const identity = Reflect.get(value, "identity"); + if (typeof identity !== "string" || !isGeneratedIdentity(identity)) { + return ( + `offers its ${form} form under "${String(identity)}", which is not an absolute URL in ` + + `${GENERATED_IDENTITY_ORIGIN}` + ); + } + return undefined; +} + +/** + * Whether this string is an identity a generated form may be offered under. + * + * Parsed as a URL and compared by origin, never by prefix: a host reading the + * text instead of the origin would admit `https://executable.md.example.com/` + * and `https://executable.md@elsewhere.example/`, which start with the accepted + * string and name somebody else. + */ +export function isGeneratedIdentity(identity: string): boolean { + try { + return new URL(identity).origin === GENERATED_IDENTITY_ORIGIN; + } catch { + return false; + } +} + +/** + * This metadata as the engine keeps it: a frozen copy, down to each form. + * + * Copied because the declaration's object is the declarer's, and frozen through + * because a shared definition is read by every execution in the process — a + * nested record left mutable would be what the next execution took its own + * first copy of, with the change already in it. + */ +export function captureGenerated(generated: GeneratedForms): GeneratedForms { + const captured: Partial> = {}; + for (const form of FORMS) { + const offered = generated[form]; + if (offered !== undefined) { + captured[form] = Object.freeze({ identity: offered.identity, effect: offered.effect }); + } + } + return Object.freeze(captured); +} + +/** + * The identity and effect one authored form is offered under, when it is. + * + * The one read every admitting table performs, so a selected form that carries + * no metadata is one answer rather than each caller's own lookup. + */ +export function offeredForm( + generated: GeneratedForms | undefined, + form: InvocationForm, +): GeneratedForm | undefined { + return generated?.[form]; +} + +/** + * Why a definition's own generated metadata is unusable, or nothing. + * + * For a definition that never passed registration — a provider's published + * answer — where both members have to be read off the object defensively + * rather than destructured from a declared shape. The accepted forms are read + * the same way, because a definition stating forms this engine does not have + * accepts none of them. + */ +export function definitionGeneratedRefusal(definition: object): string | undefined { + const generated = Reflect.get(definition, "generated"); + if (generated === undefined) { + return undefined; + } + return generatedRefusal(generated, acceptedForms(Reflect.get(definition, "forms"))); +} + +/** + * The authored forms this value states, or `undefined` for a value that states + * none — which is every form, exactly as an omitted declaration means both. + */ +function acceptedForms(value: unknown): readonly InvocationForm[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const accepted: InvocationForm[] = []; + for (const member of value) { + const form = authoredForm(member); + if (form !== undefined) { + accepted.push(form); + } + } + return accepted; +} diff --git a/packages/core/src/components/registration.ts b/packages/core/src/components/registration.ts index 9670f2cb6..50fc4bc7d 100644 --- a/packages/core/src/components/registration.ts +++ b/packages/core/src/components/registration.ts @@ -19,6 +19,8 @@ import { updateOwn } from "../scope-local.ts"; import { RESERVED_STRUCTURAL } from "../structural.ts"; import { PROTECTED_COMPONENT_NAMES, protectedNameRefusal } from "./protected.ts"; import { formsRefusal } from "../invocation-identity.ts"; +import { captureGenerated, generatedRefusal } from "./generated-metadata.ts"; +import type { GeneratedForms } from "./generated-metadata.ts"; import { documentationOf } from "./documentation.ts"; import type { ComponentDocumentation } from "./documentation.ts"; import { compilePropsSchema, compileReturnsSchema } from "../validate.ts"; @@ -81,6 +83,14 @@ export interface ComponentRegistration extends ComponentDocumentation { * decision. */ forms?: readonly InvocationForm[]; + /** + * The generated use each authored form is offered for. + * + * Stated here because this registration owns the implementation: which URL a + * continuation compares and whether a spelling observes or mutates are facts + * about the component rather than about the host table that admits it. + */ + generated?: GeneratedForms; fn: FunctionComponent | TestHarnessComponentDefinition; } @@ -244,7 +254,7 @@ function assertUsableCaptures( * implementation, so it is usable where there is no implementation yet. */ export function* admitDeclaration(declaration: ComponentDeclaration): Operation { - const { name, origin, props, returns, captures, forms } = declaration; + const { name, origin, props, returns, captures, forms, generated } = declaration; assertUsableName(name); if (origin.length === 0) { throw new ComponentRegistrationError( @@ -260,6 +270,10 @@ export function* admitDeclaration(declaration: ComponentDeclaration): Operation< if (badForms !== undefined) { throw new ComponentRegistrationError(`the declaration for "${name}" ${badForms}`); } + const badGenerated = generatedRefusal(generated, forms); + if (badGenerated !== undefined) { + throw new ComponentRegistrationError(`the declaration for "${name}" ${badGenerated}`); + } } /** Everything a declaration says about itself, apart from what implements it. */ @@ -270,6 +284,7 @@ export interface ComponentDeclaration extends ComponentDocumentation { readonly returns?: ReturnsSchema; readonly captures?: readonly string[]; readonly forms?: readonly InvocationForm[]; + readonly generated?: GeneratedForms; } export function* registerComponents( @@ -283,7 +298,7 @@ export function* registerComponents( const additions = new Map>>(); for (const registration of registrations) { - const { name, origin, fn, props, returns, captures, forms } = registration; + const { name, origin, fn, props, returns, captures, forms, generated } = registration; yield* admitDeclaration(registration); const kind = kindOf(registration); @@ -300,6 +315,7 @@ export function* registerComponents( ...(returns ? { returns } : {}), ...(captures && captures.length > 0 ? { captures } : {}), ...(forms ? { forms } : {}), + ...(generated === undefined ? {} : { generated: captureGenerated(generated) }), ...documentationOf(registration), }; batch.set(name, { ...batch.get(name), [kind]: { definition, origin } }); diff --git a/packages/core/src/components/registry.ts b/packages/core/src/components/registry.ts index 82f3ef794..e7f479953 100644 --- a/packages/core/src/components/registry.ts +++ b/packages/core/src/components/registry.ts @@ -35,6 +35,8 @@ import Test, { props as testProps } from "./Test.ts"; import { parseJsonObject } from "../json.ts"; import { declaredForms, formDispatcher } from "../invocation-identity.ts"; import { documented } from "./documentation.ts"; +import { captureGenerated } from "./generated-metadata.ts"; +import type { GeneratedForms } from "./generated-metadata.ts"; import type { FirstPartyDocumentation } from "./documentation.ts"; import type { FormDeclaration, @@ -57,6 +59,15 @@ export const CORE_ORIGIN = "@executablemd/core"; interface CoreOptions { returns?: ReturnsSchema; captures?: readonly string[]; + /** + * The generated use each authored form is offered for. + * + * On the registration that owns the implementation, because that is what a + * generated table admits: ``, `` and `` are offered, + * each in the one spelling it is written in, and every other core default + * offers none. + */ + generated?: GeneratedForms; /** * The forms this component accepts, when it is form-sensitive without being * a dispatcher. @@ -92,7 +103,7 @@ function core( ): [string, RegistryEntry] { const declaration = typeof implementation === "function" ? undefined : implementation; const fn = typeof implementation === "function" ? implementation : formDispatcher(implementation); - const { returns, captures } = options; + const { returns, captures, generated } = options; const forms = declaration === undefined ? options.forms : declaredForms(declaration); return [ name, @@ -105,6 +116,7 @@ function core( ...(returns === undefined ? {} : { returns }), ...(captures === undefined ? {} : { captures }), ...(forms === undefined ? {} : { forms }), + ...(generated === undefined ? {} : { generated: captureGenerated(generated) }), ...documented(documentation), fn, }, @@ -149,7 +161,12 @@ export const CORE_REGISTRY: ComponentRegistry = new Map([ as: "Required. The validated answer, as a structured value.", context: "The request message the person is shown.", }, - { returns: parseJsonObject(elicitReturns) }, + { + returns: parseJsonObject(elicitReturns), + generated: { + paired: { identity: "https://executable.md/components/elicit/ask", effect: "write" }, + }, + }, ), core("TempDir", TempDir, parseJsonObject(tempDirProps), { description: @@ -167,16 +184,26 @@ export const CORE_REGISTRY: ComponentRegistry = new Map([ as: null, context: null, }), - core("Fetch", Fetch, parseJsonObject(fetchProps), { - description: - 'Fetch a URL. `` binds the ' + - "response, and any status is data you can branch on. Without `as`, a non-2xx status " + - "fails. Only GET and HEAD methods currently supported.", - as: - "Optional. Captures the response, which is what makes a non-2xx status data instead " + - "of a failure.", - context: null, - }), + core( + "Fetch", + Fetch, + parseJsonObject(fetchProps), + { + description: + 'Fetch a URL. `` binds the ' + + "response, and any status is data you can branch on. Without `as`, a non-2xx status " + + "fails. Only GET and HEAD methods currently supported.", + as: + "Optional. Captures the response, which is what makes a non-2xx status data instead " + + "of a failure.", + context: null, + }, + { + generated: { + "self-closing": { identity: "https://executable.md/components/fetch/read", effect: "read" }, + }, + }, + ), core( "Glob", Glob, @@ -189,7 +216,12 @@ export const CORE_REGISTRY: ComponentRegistry = new Map([ as: "Required. The matched paths, as a string array.", context: null, }, - { returns: parseJsonObject(globReturns) }, + { + returns: parseJsonObject(globReturns), + generated: { + "self-closing": { identity: "https://executable.md/components/glob/read", effect: "read" }, + }, + }, ), core("CodeBlock", codeBlockForm, parseJsonObject(codeBlockProps), { description: diff --git a/packages/core/src/evaluation-profile.ts b/packages/core/src/evaluation-profile.ts index 40499f5c7..9cd53d0c1 100644 --- a/packages/core/src/evaluation-profile.ts +++ b/packages/core/src/evaluation-profile.ts @@ -57,31 +57,15 @@ 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 { CORE_REVISION, pinnedJson } from "./generated-xmd.ts"; +import { JSON_COMPOSITION_IDENTITY, pinnedJson } from "./generated-xmd.ts"; import type { GeneratedRequest } from "./generated-xmd.ts"; +import { offeredForm } from "./components/generated-metadata.ts"; +import type { GeneratedEffect } from "./components/generated-metadata.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"; -/** - * What a run retains about which implementation an entry runs. - * - * Three structural parts rather than one opaque string, because a continuation - * compares them and a reader has to be able to say which one moved. Never - * derived from a function: an implementation is not an identity, and - * serializing or inspecting one would make the retained policy depend on how a - * host happened to write its code. - */ -export interface FragmentIdentity { - /** The package or host that owns this implementation. */ - readonly origin: string; - /** Which component of that origin, including the form when forms differ. */ - readonly key: string; - /** Which version of it. A changed revision is a changed grant. */ - readonly revision: string; -} - /** * One component a generated fragment may name, as the host states it. * @@ -103,13 +87,13 @@ export interface CapabilityEntry { readonly kind: "capability"; /** The name a fragment writes. */ readonly name: string; - readonly identity: FragmentIdentity; /** * 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 — so the form - * travels with the identity and preflight chooses between them. + * 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. */ @@ -134,26 +118,6 @@ export interface CapabilityEntry { * (`fragment-capabilities.ts`). */ readonly capability: FragmentCapability; - /** - * The exact version-1 identity strings this entry is the successor of. - * - * Version-1 records retained one opaque string, chosen by whoever built the - * pinned entry: core wrote `@executablemd/core#File:read`, the workflow host - * wrote `@executablemd/workflow/composition/dir-v2#Dir`, and a host passing - * its own observation wrote whatever it liked. There is no rule that recovers - * four structural terms from one of those, and inferring one would be this - * module deciding on a host's behalf that two grants are the same. - * - * So it is stated rather than derived, by the party that owns the entry. - * Listing a string here is an assertion that this entry authorizes no more - * than the entry that string named — a continuation admitted under it resumes - * against this one. An entry that lists none is a new grant, and every - * version-1 record naming it refuses. - * - * Only a capability arm has this. The component-answer arm did not exist when - * version-1 records were written, so no such string ever described one. - */ - readonly legacy?: readonly string[]; /** * The exact requests this entry may perform, for an entry that performs any. * @@ -167,33 +131,26 @@ export interface CapabilityEntry { /** * An entry whose implementation the ordinary import chain answers for. * - * The host states *what it expects* — the name, the structural identity a - * provider must state, and the forms — and states no implementation at all. - * There is no definition, function, resolver, provider operation or context - * handle on this arm to hold one. + * 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; reads the identity the provider stated on that exact final answer; - * compares it with what this entry expects; retains the answer defensively; - * and seals the result. Props and the callable definition are derived from - * that answer rather than restated here, so a host cannot describe a contract - * the implementation does not have. + * 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. - * - * This arm states no version-1 alias, and has nowhere to put one: version-1 - * records predate it, so no string an older run committed ever described an - * implementation the import chain answered for. */ export interface ComponentAnswerEntry { readonly kind: "component-answer"; readonly name: string; - /** What a provider must have stated for this name, exactly. */ - readonly identity: FragmentIdentity; readonly forms: readonly FragmentForm[]; readonly description?: string; } @@ -248,9 +205,10 @@ export interface FragmentEvaluationInput { * 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. The form travels with the identity - * and preflight decides between the two — before the first effect, rather than - * inside a body that has already been entered. + * 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 reaches the `readTextFile` the host handed the profile and * nothing else. Under a workflow run that is the transaction-bound operation, @@ -258,7 +216,7 @@ export interface FragmentEvaluationInput { * is resolve a provider through the contextual API at the moment it runs. */ export function fileReadEntry(): CapabilityEntry { - return coreEntry("File", "File:read", "file:read"); + return coreEntry("File", "file:read"); } /** @@ -272,19 +230,19 @@ export function fileReadEntry(): CapabilityEntry { * it answers with are the ones that provider produced. */ export function globReadEntry(): CapabilityEntry { - return coreEntry("Glob", "Glob", "files:glob"); + 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 stating an identity - * is what makes a name admissible: a host that wrote ``'s own - * canonical identity 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 carrying no identity - * and is refused. + * 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]); @@ -293,10 +251,11 @@ export const CANONICAL_PROFILE_ANSWERS: ReadonlySet = new Set([SYNTAX_CO * * 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 what a resolution must have answered with — core's - * own origin, key and revision — and canonical capture resolves the name once - * through the ordinary import chain, reconciles that identity, and seals the - * protected answer through the execution's own protected-body route. + * 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 @@ -307,7 +266,6 @@ export function syntaxReadEntry(): ComponentAnswerEntry { return { kind: "component-answer", name: SYNTAX_COMPONENT, - identity: { origin: CORE_ORIGIN, key: SYNTAX_COMPONENT, revision: CORE_REVISION }, forms: ["self-closing"], description: "Render the components this evaluation may write, or selected documentation. " + @@ -343,24 +301,24 @@ export function syntaxReadEntry(): ComponentAnswerEntry { * a request with nothing in it. */ export function elicitWriteEntry(): CapabilityEntry { - return coreEntry(ELICIT_COMPONENT, ELICIT_COMPONENT, "elicit:ask"); + return coreEntry(ELICIT_COMPONENT, "elicit:ask"); } /** Core's `…`, admitted to write and not to read. */ export function fileWriteEntry(): CapabilityEntry { - return coreEntry("File", "File:write", "file:write"); + return coreEntry("File", "file:write"); } /** * Core's ``, in the one form it has. * - * One name, one identity: 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. + * 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", "file:delete"); + return coreEntry("File.Delete", "file:delete"); } /** @@ -368,30 +326,18 @@ export function fileDeleteEntry(): CapabilityEntry { * * 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 - * operation — `ensureDirectory`, from the profile — so the body a fragment - * reaches is not the ordinary registration and cannot be composed around. + * 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( - identity: FragmentIdentity, - name: string, - /** - * The version-1 strings this host's own directory entry succeeds. - * - * The host's to state, because the identity is: core never wrote one of - * these, so it has nothing to assert about which older grant this entry is - * the same as. - */ - legacy?: readonly string[], -): CapabilityEntry { +export function directoryEntry(name: string): CapabilityEntry { return { kind: "capability", name, - identity, forms: [...CAPABILITY_FORMS["directory:ensure"]], capability: "directory:ensure", props: capabilityProps("directory:ensure"), description: CORE_DESCRIPTIONS["directory:ensure"], - ...(legacy === undefined ? {} : { legacy }), }; } @@ -404,7 +350,7 @@ export function directoryEntry( * and refusing everything it asks for. */ export function fetchEntry(requests: readonly GeneratedRequest[]): CapabilityEntry { - return { ...coreEntry("Fetch", "Fetch", "fetch"), requests }; + return { ...coreEntry("Fetch", "fetch"), requests }; } /** What core's own entries tell an agent they do. */ @@ -423,45 +369,14 @@ const CORE_DESCRIPTIONS: Readonly> = Object.f fetch: "Perform one admitted HTTP read. Written self-closing.", }); -/** - * The exact strings each of core's own entries was retained as under version 1. - * - * Enumerated literally rather than assembled from the origin and the key. - * There was never a rule that produced these — a released build wrote each one - * where it built the pinned entry — so a template here would be this module - * inventing the convention the whole design says does not exist, and a later - * change to `CORE_ORIGIN` would silently rewrite what an old journal is - * compared against. Core states them because core owns these entries and is the - * party that can assert the current one authorizes no more than the old did. - */ -const CORE_LEGACY: Readonly> = Object.freeze({ - "file:read": Object.freeze(["@executablemd/core#File:read"]), - // Core never pinned a search entry under version 1; there is no older grant - // for this one to assert it authorizes no more than. - "files:glob": Object.freeze([]), - "file:write": Object.freeze(["@executablemd/core#File:write"]), - "file:delete": Object.freeze(["@executablemd/core#File.Delete"]), - // Core never pinned a directory entry under version 1; the workflow host did, - // and states its own alias. - "directory:ensure": Object.freeze([]), - fetch: Object.freeze(["@executablemd/core#Fetch"]), - // A new grant. No version-1 record ever admitted a generated question, so - // there is no older string for this to assert it authorizes no more than — - // and a record naming one refuses rather than resuming against this. - "elicit:ask": Object.freeze([]), -}); - -function coreEntry(name: string, key: string, capability: FragmentCapability): CapabilityEntry { - const legacy = CORE_LEGACY[capability]; +function coreEntry(name: string, capability: FragmentCapability): CapabilityEntry { return { kind: "capability", name, - identity: { origin: CORE_ORIGIN, key, revision: CORE_REVISION }, forms: [...CAPABILITY_FORMS[capability]], capability, props: capabilityProps(capability), description: CORE_DESCRIPTIONS[capability], - ...(legacy.length === 0 ? {} : { legacy }), }; } @@ -469,15 +384,16 @@ function coreEntry(name: string, key: string, capability: FragmentCapability): C * 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 provider's identity statement - * has been read off that exact final object, it has been compared with what - * the host entry expects, and the answer 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. + * 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. */ 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. */ + readonly origin: string; } /** Every provider-backed name this capture resolved, by name. */ @@ -489,12 +405,26 @@ export type ResolvedAnswers = ReadonlyMap; * `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 + * for one fact. */ export interface CapturedEntry { /** Documentation provenance of an exact routed answer; grants no callable permission. */ readonly protectedOrigin?: string; readonly name: string; - readonly identity: FragmentIdentity; + /** + * 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. + */ + readonly origin: string; readonly forms: readonly FragmentForm[]; readonly props: PropsSchema; readonly kind: "capability" | "component-answer" | "composition"; @@ -524,15 +454,6 @@ export interface CapturedEntry { * states none. */ readonly dispatch?: unknown; - /** - * The version-1 identity strings this entry succeeds, copied by value. - * - * Read off the host's array once at capture and frozen, like everything else - * here: a host that appends to its own list afterwards is appending to an - * object nothing is looking at, so it cannot widen which retained records - * reconcile to this entry from inside its own `install()`. - */ - readonly legacy?: readonly string[]; /** This entry's own ceiling, normalized once and canonically ordered. */ readonly requests?: readonly FetchRequest[]; } @@ -590,23 +511,24 @@ export class EvaluationProfileError extends Error { * 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 identity, the forms, - * the schema for a capability, the ceiling. What is missing is exactly the part - * a host does not state: a component answer's implementation and the contract - * that comes with it. + * 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. */ interface PreparedEntry { readonly name: string; - readonly identity: FragmentIdentity; 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 legacy?: readonly string[]; readonly requests?: readonly FetchRequest[]; } @@ -626,12 +548,15 @@ interface PreparedEntry { */ export interface PreparedProfile { /** - * The provider-backed names this profile admits, with what it expects of each. + * The provider-backed names this profile admits. * - * Empty for a capability-only profile, which performs no component-chain - * lookup at all — a host that offers no provider answer pays for none. + * 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 { readonly name: string; readonly identity: FragmentIdentity }[]; + 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. */ @@ -666,8 +591,8 @@ export function* prepareEvaluationProfile( // 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 ?? [], input.fetchTimeout, built); - const write = yield* prepareEntries(input.write ?? [], input.fetchTimeout, built); + 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 " + @@ -693,9 +618,7 @@ export function* prepareEvaluationProfile( const answered = answeredNames([...read, ...write]); const workspace = input.workspace === undefined ? undefined : bindWorkspace(input.workspace); return Object.freeze({ - answered: Object.freeze( - [...answered.entries()].map(([name, identity]) => Object.freeze({ name, identity })), - ), + answered: Object.freeze([...answered]), live: () => capabilities.live(), revoke: () => { capabilities.revoke(); @@ -740,11 +663,7 @@ function compositionEntries(): readonly CapturedEntry[] { [pinnedJson()].map((entry) => Object.freeze({ name: entry.name, - identity: Object.freeze({ - origin: entry.identity.origin, - key: entry.identity.key, - revision: entry.identity.revision, - }), + origin: CORE_ORIGIN, forms: Object.freeze(["self-closing"]), props: detach(entry.definition.props), kind: "composition" as const, @@ -852,18 +771,20 @@ function normalizedCeiling( 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, fetchTimeout, built)); + prepared.push(yield* prepareEntry(entry, effect, fetchTimeout, built)); } return Object.freeze(prepared); } function* prepareEntry( entry: FragmentEntry, + effect: GeneratedEffect, fetchTimeout: number | undefined, built: Map, ): Operation { @@ -873,7 +794,6 @@ function* prepareEntry( `an evaluation profile admitted "${entry.name}" for no authored form.`, ); } - const identity = captureIdentity(entry.identity, entry.name); const described = typeof entry.description === "string" && entry.description.length > 0 ? { description: entry.description } @@ -881,14 +801,15 @@ function* prepareEntry( if (entry.kind === "component-answer") { // Nothing about the implementation is settled here. What this entry states - // is which name, and which identity a provider must have claimed for it; - // the chain answers later, and props come from that answer rather than from - // the host, so a host cannot describe a contract the implementation lacks. + // 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, - identity, forms, kind: "component-answer" as const, + effect, ...described, }); } @@ -908,69 +829,40 @@ function* prepareEntry( "an evaluation profile admitted a name with no operation behind it.", ); } - const legacy = captureLegacy(entry.legacy, entry.name); - return Object.freeze({ + const prepared: PreparedEntry = Object.freeze({ name: entry.name, - identity, forms, props, kind: "capability" as const, + effect, capability: entry.capability, ...described, definition: implementation.definition, ...(implementation.dispatch === undefined ? {} : { dispatch: implementation.dispatch }), - ...(legacy === undefined ? {} : { legacy }), ...(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 version-1 aliases this entry states, copied and checked. - * - * Copied because the host's array is the host's, and checked because an alias - * is a comparison term: an empty string would reconcile against nothing - * usefully, and a duplicate would say one thing twice. A host stating an empty - * list has stated no alias, which is the same as stating none. - */ -function captureLegacy( - legacy: readonly string[] | undefined, - name: string, -): readonly string[] | undefined { - if (legacy === undefined) { - return undefined; - } - const held = new Set(); - for (const alias of legacy) { - if (typeof alias !== "string" || alias.length === 0) { - throw new EvaluationProfileError( - `an evaluation profile stated an empty version-1 identity for "${name}". An alias is a ` + - "string an older run actually retained, and there is no such record holding nothing.", - ); - } - held.add(alias); - } - return held.size === 0 ? undefined : Object.freeze([...held]); -} - -/** - * The provider-backed names this profile admits, with the one identity each of - * them states. + * The provider-backed names this profile admits. * - * A name is one component, so it has one implementation and one identity. Two + * 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 - * stating a second identity for the second spelling would be stating that the - * name means two things, with which of them a fragment reached decided by which - * table admitted it. That is not a narrower grant than the host wrote down; it - * is an ambiguous one, so it refuses at capture rather than resolving by - * position. + * 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 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. + * 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[]): ReadonlyMap { - const answered = new Map(); +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); @@ -982,37 +874,17 @@ function answeredNames(prepared: readonly PreparedEntry[]): ReadonlyMap, + answered: ReadonlySet, answers: ResolvedAnswers, capabilities: CapturedCapabilities, project: ComponentRouting["project"] | undefined, ): ReadonlyMap { const sealed = new Map(); - for (const name of answered.keys()) { + for (const name of answered) { // Resolved once, before any document code, through the complete ordinary - // import chain — and already reconciled against what this entry expects. + // import chain, and published there by the provider that supplied it. const resolved = answers.get(name); if (resolved === undefined) { throw new EvaluationProfileError( @@ -1050,6 +922,7 @@ function sealAnswers( sealed.set( name, Object.freeze({ + origin: resolved.origin, props: detach(answer.props), definition: Object.freeze({ ...answer, fn: guard }), ...(protectedOrigin === undefined ? {} : { protectedOrigin }), @@ -1082,9 +955,14 @@ function sealEntry(entry: PreparedEntry, sealed: ReadonlyMap as ${identityRecord(expected)}, and the import ` + - `chain answered with ${identityRecord(stated)}. An admitted identity is the exact ` + - "implementation a fragment may run." - ); -} +/** What a provider-backed name no provider published an answer 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 the identity comparison 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 — is not this, so it - * receives no canonical identity and the entry refuses it. + * 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); @@ -810,16 +788,17 @@ 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. The provider's identity statement is - * read off the *exact final answer* rather than off anything the chain held in - * between, and that one call answers with both the identity and core's own - * copy of what was identified — taken when the identity was recorded, 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. - * - * The identity is then compared whole with what the host entry expects, and the - * copy — not the answer — is what the profile seals. + * 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. */ function* resolveComponentAnswers( prepared: PreparedProfile, @@ -831,21 +810,13 @@ function* resolveComponentAnswers( // A capability-only profile performs no component-chain lookup at all. return answers; } - // The names this profile admits under canonical core's own identity. A - // protected component is nobody's to answer for, so no provider states what - // is behind one — core does, here, and only where the entry says it expects - // core. A host that admits a protected name under a provider's identity is - // still that provider's to claim, exactly as it was. + // 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, identity }) => - CANONICAL_PROFILE_ANSWERS.has(name) && - identity.origin === CORE_ORIGIN && - identity.key === name && - identity.revision === CORE_REVISION, - ) - .map(({ name }) => name), + prepared.answered.filter((name) => CANONICAL_PROFILE_ANSWERS.has(name)), ); const canonicalProvider = canonical.size === 0 ? undefined : imports.provider(CORE_ORIGIN); yield* scoped(function* () { @@ -853,11 +824,13 @@ function* resolveComponentAnswers( { *importComponent([name, position], _next) { const answer = yield* resolveImportUnrecorded(name, inputs); - // Stated from the innermost position, on the exact object + // Published from the innermost position, on the exact object // materialization produced, so a handler composed around this one - // states nothing: middleware that returns this answer unchanged - // leaves the claim on it, and middleware that returns anything else - // returns something carrying no identity at all. + // 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) || @@ -867,7 +840,7 @@ function* resolveComponentAnswers( } const asked = canonicalProvider.open(name, position); try { - return asked.request.claim(answer, { key: name, revision: CORE_REVISION }); + return asked.request.answer(answer); } finally { asked.close(); } @@ -875,16 +848,16 @@ function* resolveComponentAnswers( }, { at: "min" }, ); - for (const { name, identity } of prepared.answered) { - // One resolution, one claim window. It opens before the chain is asked - // and closes however this iteration leaves — answered, refused, or + for (const name of prepared.answered) { + // 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, states + // one still holding its request while the *next* name resolves, publishes // nothing into a decision that is already made. // - // One call, one result. The claim and core's copy of what was claimed - // come back together, and the object the chain returned is not read - // again — so nothing this run keeps was decided by a second read. + // 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 { @@ -897,16 +870,9 @@ function* resolveComponentAnswers( resolution.close(); } if (identified === undefined) { - throw new EvaluationProfileError(UNIDENTIFIED_ANSWER); - } - const { identity: stated, definition: own } = identified; - if ( - stated.origin !== identity.origin || - stated.key !== identity.key || - stated.revision !== identity.revision - ) { - throw new EvaluationProfileError(differentAnswer(name, identity, stated)); + throw new EvaluationProfileError(UNPUBLISHED_ANSWER); } + const { origin, definition: own } = identified; // 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 @@ -917,7 +883,7 @@ function* resolveComponentAnswers( "definition that has no implementation to invoke. An admitted entry runs something.", ); } - answers.set(name, { definition: own }); + answers.set(name, { definition: own, origin }); } }); return answers; diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index 818e7ffa3..df42c3721 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -96,14 +96,14 @@ * ``, the Workspace file effect for `` — so a replay restores both * without asking anyone anything a second time. * - * An identity in that record is a closed tagged structural record: what kind of - * thing is behind the name, the origin that owns it, the component key, and the - * revision. Four terms rather than one spelling, so a reader comparing two - * admissions can say which of them moved and no two hosts have to agree on a - * separator. Version 1 — the untagged #369 record — wrote one opaque string - * instead, and stays both readable *and* resumable: a version-1 string is - * reconciled against a current capability identity under the spelling that - * build used. Nothing this build writes uses that spelling. + * An identity in that record is the URL the declaration that owns the + * implementation offers that spelling under — `https://executable.md/components/file/read` + * for `` and `.../file/write` for `…` — retained beside + * what kind of thing answered for the name and which effect table admitted it. + * One component, one definition, one identity per form, all of them stated + * where the implementation is. A record written by an earlier build holds a + * shape this one does not have, and is refused as unreadable history before any + * generated work rather than read as whichever current shape it most resembles. */ import { createDurableOperation } from "@executablemd/durable-streams"; @@ -119,6 +119,7 @@ import type { ComponentResolution, ImportedDefinition } from "./components/compo import { isComponentName } from "./components/registration.ts"; import { CORE_ORIGIN, CORE_REGISTRY } from "./components/registry.ts"; import { fixedFilesystemDefinition } from "./components/fixed-filesystem.ts"; +import { isGeneratedIdentity, offeredForm } from "./components/generated-metadata.ts"; import { createBlockCounter, expandSegmentsWithin } from "./expand.ts"; import { extendPath } from "./expansion.ts"; import { prepareFetchRequest, requestRecord } from "./fetch-request.ts"; @@ -312,131 +313,37 @@ export type GeneratedRequest = Record; export type GeneratedEffectClass = "read" | "write"; /** - * Which implementation one admitted entry runs, as the run retains it. - * - * A closed tagged record rather than a string, and structural rather than - * assembled, because this is what a continuation compares. Four terms, each - * with one meaning: a reader looking at two admissions can say *which* of them - * moved, and no host has to agree with another about how to spell a separator. - * - * `kind` is part of the identity rather than beside it. An operation canonical - * core supplies the body for and an implementation the ordinary import chain - * answered with are different grants even under one origin, key and revision — - * the first cannot be composed around and the second was resolved through - * middleware — so a record that confused them would compare a fragment's - * admission equal to an admission it never had. + * Whether canonical core supplies the body behind an admitted name, or the + * ordinary import chain answered for it. * - * Never derived from a function. An implementation is not an identity, and - * serializing or inspecting one would make the retained policy depend on how a - * host happened to write its code. Functions never enter the journal. + * Retained beside the identity, because the two are different grants even under + * one URL: the first cannot be composed around and the second was resolved + * through middleware, so a record that confused them would compare a fragment's + * admission equal to an admission it never had. */ -export type RetainedFragmentIdentity = - | { - readonly kind: "capability"; - readonly origin: string; - readonly key: string; - readonly revision: string; - } - | { - readonly kind: "component-answer"; - readonly origin: string; - readonly key: string; - readonly revision: string; - }; +export type GeneratedImplementation = "capability" | "component-answer"; /** The two ways a host can say what is behind an admitted name. */ -const IDENTITY_KINDS: readonly RetainedFragmentIdentity["kind"][] = [ - "capability", - "component-answer", -]; - -/** - * What a record retains for one entry: this version's identity, or version 1's. - * - * Version 1 retained one opaque string. It is kept as the string it is rather - * than parsed into a shape it never had, and reconciled against a version-2 - * identity under the spelling the build that wrote it used — so an older record - * stays readable *and* stays a grant, instead of being readable and useless. - */ -type RetainedIdentity = RetainedFragmentIdentity | LegacyIdentity; - -/** One version-1 identity: the exact string an older build wrote. */ -interface LegacyIdentity { - readonly legacy: string; -} - -/** - * One identity as a map key, for the run's own tables. - * - * Never retained and never compared: what a continuation is held to is the - * record's four members, read one at a time. This exists because a `Map` needs - * a primitive, and it carries the kind so two grants that differ only in how - * they were resolved do not share a ceiling. - * - * The encoding is JSON rather than the four terms joined by a separator, - * because a joined key is only as unique as the separator is illegal. A host - * may state an origin or a key holding any character at all — a URL with a - * space in it, a component key with a `#` — and two identities that differ only - * in where the separator falls would key one entry's request ceiling under - * another's. JSON escapes what it encodes, so distinct terms encode distinctly. - */ -function identityKey(identity: RetainedFragmentIdentity): string { - return JSON.stringify([identity.kind, identity.origin, identity.key, identity.revision]); -} +const IMPLEMENTATIONS: readonly GeneratedImplementation[] = ["capability", "component-answer"]; /** - * Whether two version-2 identities describe the same implementation. + * Whether the row this run states is the one a retained row was admitted under. * - * Member by member, kind included: an operation canonical core supplies the - * body for and an implementation the ordinary import chain answered with are - * different grants even under one origin, key and revision. + * Member by member: the URL the spelling runs under, the spelling itself, what + * kind of thing answered for the name, and which effect table admitted it. A + * changed URL, a form added, an implementation resolved another way and a read + * moved into the write table are each a different grant. */ -function sameStructural(one: RetainedFragmentIdentity, other: RetainedFragmentIdentity): boolean { +function sameRow(retained: RetainedEntry, current: RetainedEntry): boolean { return ( - one.kind === other.kind && - one.origin === other.origin && - one.key === other.key && - one.revision === other.revision + retained.name === current.name && + retained.form === current.form && + retained.identity === current.identity && + retained.kind === current.kind && + retained.effect === current.effect ); } -/** - * Whether the entry this run states is the one a retained record was admitted - * under. - * - * Two version-2 records compare structurally, and two version-1 strings compare - * as the strings they are. - * - * A version-1 string against a version-2 entry is the case that cannot be - * decided by looking at either one. Version 1 retained one opaque value chosen - * by whoever built the pinned entry — `@executablemd/core#File:read` from core, - * `@executablemd/workflow/composition/dir-v2#Dir` from the workflow host, and - * whatever a host passed to `pinnedComponent` from anyone else. No rule - * recovers four structural terms from one of those, and a rule that appeared to - * would only be reading the shapes core happens to use today back onto records - * core did not write. - * - * So it is not inferred. The *current* entry states which version-1 strings it - * succeeds, and a retained string reconciles only against that stated list. - * Listing one is an assertion by the party that owns the entry; an entry that - * lists none refuses every version-1 record naming it, which is the safe - * direction. New records state the structural identity and never a string. - */ -function sameIdentity(retained: RetainedIdentity, current: RetainedEntry): boolean { - const here = current.identity; - if ("legacy" in retained) { - if ("legacy" in here) { - return retained.legacy === here.legacy; - } - // Only a capability reconciles. The component-answer arm did not exist when - // version-1 records were written, so no such string ever described one — - // and the kind is checked here rather than left to the profile, because - // this is the comparison a continuation is actually held to. - return here.kind === "capability" && current.legacy?.includes(retained.legacy) === true; - } - return "legacy" in here ? false : sameStructural(retained, here); -} - /** * The authored forms one pinned identity runs in. * @@ -461,15 +368,21 @@ function authoredForms(form: GeneratedComponentForm): readonly AuthoredForm[] { * One observation component a generated fragment may name, and the exact * definition that name runs. * - * `identity` is the stable, non-secret descriptor the run retains: it says - * which implementation was admitted, and holding it grants nothing. It is also - * what a continuation is compared against, so changing the definition behind a - * name means changing this. + * There is no identity beside the definition. Which URL each admitted spelling + * runs under, and whether that spelling observes or mutates, is declared by + * whoever owns the implementation and travels on the definition — so a host + * admits a name and never describes what admitting it grants. A continuation is + * compared against those URLs, so changing the definition behind a name means + * changing what its owner declares. */ export interface GeneratedObservation { readonly name: string; - readonly identity: RetainedFragmentIdentity; readonly definition: FunctionComponentDefinition; + /** + * Whether canonical core supplies this body or the import chain answered for + * it. Omitted is core's own operation, which is what a pinned entry is. + */ + readonly kind?: GeneratedImplementation; /** * The form dispatcher underneath this entry's implementation, when the * definition wraps one. @@ -480,76 +393,33 @@ export interface GeneratedObservation { * the ordinary case: the definition's own `fn` is the dispatcher. */ readonly dispatch?: unknown; - /** - * The exact version-1 identity strings this entry states it succeeds. - * - * Stated by whoever owns the entry rather than derived from the identity - * above, because version 1 retained one opaque value that nothing recovers - * four structural terms from. A retained version-1 record reconciles against - * this list and against nothing else. - */ - readonly legacy?: readonly string[]; /** * The exact requests this observation may perform, when it performs HTTP - * reads at all. Present only on the pinned `` identity. + * reads at all. Present only on the pinned `` entry. */ readonly requests?: readonly GeneratedRequest[]; /** * Whether only the self-closing form of this name is admitted. * - * A component whose two forms do different things has two identities, and a - * host admitting one of them is not admitting the other. `` is the case - * that matters: `hasContent()` is exactly `!selfClosing`, so the paired form - * writes. The constraint therefore travels with the pinned identity and is - * decided in preflight, before the first effect — not checked inside the - * component after earlier elements have already run. + * A component whose two forms do different things offers each of them its own + * identity and effect, and a host admitting one of them is not admitting the + * other. `` is the case that matters: `hasContent()` is exactly + * `!selfClosing`, so the paired form writes. The constraint therefore travels + * with the entry and is decided in preflight, before the first effect — not + * checked inside the component after earlier elements have already run. */ readonly selfClosing?: boolean; } /** - * The revision core's own entries state, wherever they are admitted from. - * - * One number for all of them, bumped whenever what any of these entries - * authorizes changes, so a continuation admitted under an earlier revision is - * refused rather than silently granted the newer admission. + * Core's ``, as the composition table retains it. * - * One constant rather than one per table. The pinned constructors here and the - * evaluation profile's entries describe the same operations, and a reader - * comparing two admissions has to be able to trust that they say so — two - * numbers that had to be moved together would eventually not be. - * - * Revision 2 is where the operations behind these entries changed: an admitted - * element used to invoke the ordinary component and resolve `API.Files` or - * `API.Fetch` wherever it happened to run, and now it invokes a body closed over - * the operations the host handed the profile. + * Json is trusted composition rather than an effect: there is no operation + * behind it to grant, so it carries no read or write form for an owner to + * offer. The identity is core's own, stated here because this is the one place + * that admits it. */ -export const CORE_REVISION = "2"; - -/** One of core's own pinned identities, under core's origin and revision. */ -function coreIdentity(key: string): RetainedFragmentIdentity { - return { kind: "capability", origin: CORE_ORIGIN, key, revision: CORE_REVISION }; -} - -/** - * The exact strings core's own entries were retained as under version 1. - * - * Written out one by one, and deliberately not assembled from the origin and - * the key. There was never a rule producing these — `pinnedFetch` wrote its - * string, `pinnedFileRead` wrote its own, and a host writing a fourth was under - * no obligation to resemble either — so a function that built them would be - * inventing the rule this module exists to say does not exist. Enumerating them - * is also what makes the set reviewable: adding an alias is adding a line, and - * every line is an assertion that the current entry authorizes no more than the - * one that string named. - * - * Frozen and read by value at each call site. Nothing derives one, and a fifth - * released alias would be a fifth literal here rather than a broader pattern. - */ -const CORE_V1_FETCH = Object.freeze(["@executablemd/core#Fetch"]); -const CORE_V1_FILE_READ = Object.freeze(["@executablemd/core#File:read"]); -const CORE_V1_FILE_WRITE = Object.freeze(["@executablemd/core#File:write"]); -const CORE_V1_FILE_DELETE = Object.freeze(["@executablemd/core#File.Delete"]); +export const JSON_COMPOSITION_IDENTITY = "https://executable.md/components/json"; /** * The pinned core `` identity, bounded to exactly these requests. @@ -570,13 +440,7 @@ export function pinnedFetch(requests: readonly GeneratedRequest[]): GeneratedObs "admitting to a generated fragment requires the exact requests it may perform.", ); } - return { - name: "Fetch", - identity: coreIdentity("Fetch"), - legacy: CORE_V1_FETCH, - definition, - requests: [...requests], - }; + return { name: "Fetch", definition, requests: [...requests] }; } /** @@ -585,9 +449,10 @@ export function pinnedFetch(requests: readonly GeneratedRequest[]): GeneratedObs * Core's own fixed definition, and only its self-closing spelling. `` * reads when it has no content and writes when it has some, so admitting the * unconstrained definition would admit a write — which is why this constructor - * exists rather than a caller reaching for the definition itself and hoping. The - * identity says so too, so a run that later admitted the unconstrained `File` - * would be stating a different policy and a retained admission would refuse it. + * exists rather than a caller reaching for the definition itself and hoping. + * The identity the admission retains is the one core's declaration offers that + * spelling under, so a run that later admitted the paired form would be stating + * a different policy and a retained admission would refuse it. * * An admitted read invokes the ordinary `` component and therefore the * installed Files provider, which under a workflow run is the transaction-bound @@ -598,13 +463,20 @@ export function pinnedFileRead(): GeneratedObservation { if (definition === undefined) { throw new GeneratedXmdError("core supplies no File component to admit."); } - return { - name: "File", - identity: coreIdentity("File:read"), - legacy: CORE_V1_FILE_READ, - definition, - selfClosing: true, - }; + return { name: "File", definition, selfClosing: true }; +} + +/** + * One component the trusted composition table admits, and its identity. + * + * Composition is core's own and performs nothing, so it is the one admitted + * thing whose identity is not read off an owner's generated metadata: a pure + * component offers no read or write form for an owner to declare. + */ +export interface CompositionEntry { + readonly name: string; + readonly identity: string; + readonly definition: FunctionComponentDefinition; } /** @@ -614,23 +486,18 @@ export function pinnedFileRead(): GeneratedObservation { * selects, because rendering a value it already holds performs nothing: there * is no operation behind it to grant, so admitting it widens no permission. The * exact core definition, so a same-name replacement anywhere reaches nothing - * here — purity is a property of this identity, never inferred from a name. + * here — purity is a property of this entry, never inferred from a name. * - * Self-closing alone. `` refuses content of its own, and stating the form - * here puts that refusal in preflight rather than inside the component after - * earlier elements have run. + * Self-closing alone. `` refuses content of its own, and the composition + * table admits that spelling rather than both, so the refusal is in preflight + * rather than inside the component after earlier elements have run. */ -export function pinnedJson(): GeneratedObservation { +export function pinnedJson(): CompositionEntry { const definition = CORE_REGISTRY.get("Json")?.default?.definition; if (definition === undefined || definition.kind !== "function") { throw new GeneratedXmdError("core supplies no Json component to admit."); } - return { - name: "Json", - identity: coreIdentity("Json"), - definition, - selfClosing: true, - }; + return { name: "Json", identity: JSON_COMPOSITION_IDENTITY, definition }; } /** @@ -639,30 +506,25 @@ export function pinnedJson(): GeneratedObservation { * Core's own, and not a ceiling a host states: a host cannot remove Json from a * fragment's vocabulary by omitting it, and cannot add an effect to the table * by naming one. A trusted host that wants another pure component states its - * exact definition and identity, which is a different act from this one. + * exact definition, which is a different act from this one. */ -export function coreComposition(): readonly GeneratedObservation[] { +export function coreComposition(): readonly CompositionEntry[] { return [pinnedJson()]; } /** * One host-registered function observation component, by the exact definition * the host holds. + * + * The definition states what each of its spellings is offered as, so there is + * nothing for the host to say about identity here. */ export function pinnedComponent( name: string, - identity: RetainedFragmentIdentity, definition: FunctionComponentDefinition, - /** - * The version-1 strings this host's own entry succeeds. - * - * The host's to state, because under version 1 this constructor took whatever - * string the host chose and retained it verbatim. Core has nothing to assert - * about which of those an entry is the successor of. - */ - legacy?: readonly string[], + kind?: GeneratedImplementation, ): GeneratedObservation { - return { name, identity, definition, ...(legacy === undefined ? {} : { legacy }) }; + return { name, definition, ...(kind === undefined ? {} : { kind }) }; } /** @@ -682,12 +544,14 @@ export function pinnedComponent( */ export interface GeneratedMutation { readonly name: string; - readonly identity: RetainedFragmentIdentity; readonly definition: FunctionComponentDefinition; + /** + * Whether canonical core supplies this body or the import chain answered for + * it. Omitted is core's own operation, which is what a pinned entry is. + */ + readonly kind?: GeneratedImplementation; /** The form dispatcher underneath this entry's implementation, when it wraps one. */ readonly dispatch?: unknown; - /** The exact version-1 identity strings this entry states it succeeds. */ - readonly legacy?: readonly string[]; readonly form: GeneratedComponentForm; } @@ -705,23 +569,17 @@ export function pinnedFileWrite(): GeneratedMutation { if (definition === undefined) { throw new GeneratedXmdError("core supplies no File component to admit."); } - return { - name: "File", - identity: coreIdentity("File:write"), - legacy: CORE_V1_FILE_WRITE, - definition, - form: "paired", - }; + return { name: "File", definition, form: "paired" }; } /** * The pinned core `` identity, in the one form it has. * * Core's own fixed definition, and its self-closing spelling. One name, one - * identity: unlike ``, whose two spellings do different things, this + * form: unlike ``, whose two spellings do different things, this * component answers the self-closing form and refuses the paired one, so the - * form stated here names what the identity is rather than narrowing it to part - * of it. Stating it anyway is what puts the decision in preflight, before the + * form stated here names the whole of what core offers rather than narrowing it + * to part. Stating it anyway is what puts the decision in preflight, before the * first effect of the fragment — a paired spelling costs an earlier admitted * element nothing. * @@ -738,28 +596,23 @@ export function pinnedFileDelete(): GeneratedMutation { if (definition === undefined) { throw new GeneratedXmdError("core supplies no File.Delete component to admit."); } - return { - name: "File.Delete", - identity: coreIdentity("File.Delete"), - legacy: CORE_V1_FILE_DELETE, - definition, - form: "self-closing", - }; + return { name: "File.Delete", definition, form: "self-closing" }; } /** * One host-owned mutation component, by the exact definition the host holds and * the exact form it admits. + * + * The form chooses which of the definition's own offered spellings this entry + * admits; what that spelling runs as is the definition's to say. */ export function pinnedMutation( name: string, - identity: RetainedFragmentIdentity, definition: FunctionComponentDefinition, form: GeneratedComponentForm, - /** The version-1 strings this host's own entry succeeds, as the host states them. */ - legacy?: readonly string[], + kind?: GeneratedImplementation, ): GeneratedMutation { - return { name, identity, definition, form, ...(legacy === undefined ? {} : { legacy }) }; + return { name, definition, form, ...(kind === undefined ? {} : { kind }) }; } /** What a trusted host asks this evaluator to admit. */ @@ -780,9 +633,9 @@ export interface GeneratedXmdRequest { readonly workspaceRoots?: readonly string[]; /** The one root admitted effects address, for a host that has one. */ readonly selectedRoot?: string; - /** The pinned observation identities the `read` class resolves to. */ + /** The pinned observation entries the `read` class resolves to. */ readonly observations: readonly GeneratedObservation[]; - /** The pinned mutation identities the `write` class resolves to. */ + /** The pinned mutation entries the `write` class resolves to. */ readonly mutations?: readonly GeneratedMutation[]; /** * Which effect classes this fragment may draw on. Omitted selects `read` @@ -806,11 +659,21 @@ export interface GeneratedXmdRequest { */ interface Entry { readonly name: string; - readonly identity: RetainedFragmentIdentity; + /** + * The identity each admitted spelling runs under, read off the definition. + * + * One URL per form rather than one per entry, because that is where the + * declaration that owns the implementation puts it: `` and + * `…` are one definition offering two spellings, and which one + * an element wrote decides which identity the admission retains. + */ + readonly identities: ReadonlyMap; readonly definition: FunctionComponentDefinition; /** The form dispatcher under this entry's definition, when a host stated one. */ readonly dispatch?: unknown; readonly forms: readonly AuthoredForm[]; + /** Whether core supplies this body or the import chain answered for it. */ + readonly kind: GeneratedImplementation; /** * Which table admitted this entry. * @@ -819,31 +682,29 @@ interface Entry { * select between. */ readonly effect: GeneratedEffectClass | "composition"; - /** The version-1 identity strings this entry states it succeeds. */ - readonly legacy?: readonly string[]; readonly requests?: readonly GeneratedRequest[]; } -/** The pinned identity of one admitted entry, as the run retains it. */ +/** + * One admitted spelling, as the run retains it. + * + * One row per name and form rather than per entry, because the identity and the + * effect belong to the spelling: a reader comparing two admissions sees exactly + * which spelling of which name moved, and the table a row came from is retained + * beside it rather than inferred from where it sits. + */ interface RetainedEntry { readonly name: string; - readonly identity: RetainedIdentity; - readonly forms: readonly AuthoredForm[]; - /** - * The version-1 strings this entry succeeds, on the *current* side only. - * - * Never read from a record and never written to one: a journal holds what was - * admitted, and which older grants a current entry is willing to answer for - * is a statement this run's host makes now. Reading it from the record would - * let a retained value nominate its own successor. - */ - readonly legacy?: readonly string[]; + readonly form: AuthoredForm; + readonly identity: string; + readonly kind: GeneratedImplementation; + readonly effect: GeneratedEffectClass | "composition"; } /** One element the fragment actually named, as the run retains it. */ interface RetainedInvocation { readonly name: string; - readonly identity: RetainedIdentity; + readonly identity: string; readonly form: AuthoredForm; } @@ -867,19 +728,12 @@ interface Policy { * rather than restating the same one. */ readonly workspace?: { readonly roots: readonly string[]; readonly selectedRoot: string }; - readonly allowed: readonly RetainedEntry[]; - readonly requests: readonly FetchRequest[]; /** - * Which record shape this policy was read from, for a retained one. - * - * Absent on the policy a run states now, which is always current. A version-1 - * record was written before the trusted composition table existed, so it - * names none, and holding it to entries that carry no effect operation would - * refuse a continuation over a grant nobody widened. + * Every admitted spelling, in the order the selection resolved them: + * composition first, then the read table, then the write table. */ - readonly recordVersion?: 1 | 2; - /** How many leading entries are composition, on the policy a run states now. */ - readonly composition?: number; + readonly allowed: readonly RetainedEntry[]; + readonly requests: readonly FetchRequest[]; } /** The decision this run recorded, restored from its own durable record. */ @@ -1141,9 +995,12 @@ function selectedEntries( for (const composition of coreComposition()) { entries.push({ name: composition.name, - identity: composition.identity, + // Core's own, and the one admitted identity not read off an owner's + // generated metadata: a pure component offers no effect to declare. + identities: new Map([["self-closing", composition.identity]]), definition: composition.definition, - forms: composition.selfClosing === true ? ["self-closing"] : AUTHORED_FORMS, + forms: ["self-closing"], + kind: "capability", effect: "composition", }); } @@ -1152,16 +1009,20 @@ function selectedEntries( throw new GeneratedXmdError("a generated-XMD allowlist selected `read` with no read table."); } for (const observation of request.observations) { + // The spellings this component is offered to observe in, narrowed by the + // host where it constrained the entry to the self-closing one. Derived + // rather than assumed: a component offering one reading spelling admits + // that one, and a host cannot widen it to a spelling its owner never + // offered as a read. + const forms = observedForms(observation); entries.push({ name: observation.name, - identity: observation.identity, + identities: offeredIdentities(observation.name, observation.definition, forms, "read"), definition: observation.definition, ...(observation.dispatch === undefined ? {} : { dispatch: observation.dispatch }), - // An entry the host constrained to its self-closing spelling admits - // that one; an unconstrained one admits both, as it always has. - forms: observation.selfClosing === true ? ["self-closing"] : AUTHORED_FORMS, + forms, + kind: observation.kind ?? "capability", effect: "read", - ...(observation.legacy === undefined ? {} : { legacy: observation.legacy }), ...(observation.requests === undefined ? {} : { requests: observation.requests }), }); } @@ -1174,30 +1035,92 @@ function selectedEntries( ); } for (const mutation of mutations) { + const forms = authoredForms(mutation.form); entries.push({ name: mutation.name, - identity: mutation.identity, + identities: offeredIdentities(mutation.name, mutation.definition, forms, "write"), definition: mutation.definition, ...(mutation.dispatch === undefined ? {} : { dispatch: mutation.dispatch }), - forms: authoredForms(mutation.form), + forms, + kind: mutation.kind ?? "capability", effect: "write", - ...(mutation.legacy === undefined ? {} : { legacy: mutation.legacy }), }); } } return entries; } +/** + * The spellings one observation entry admits. + * + * Every spelling its owner offers as a read, and the host may narrow that to + * the self-closing one. An entry whose owner offers no reading spelling at all + * is a table admitting a component to observe that does not observe, so it + * refuses here rather than at the first element that writes the name. + */ +function observedForms(observation: GeneratedObservation): readonly AuthoredForm[] { + const offered = AUTHORED_FORMS.filter( + (form) => offeredForm(observation.definition.generated, form)?.effect === "read", + ); + const forms = + observation.selfClosing === true ? offered.filter((form) => form === "self-closing") : offered; + if (forms.length === 0) { + throw new GeneratedXmdError( + `a generated-XMD allowlist admitted <${observation.name}> to observe, and the declaration ` + + "that owns that implementation offers no reading spelling of it.", + ); + } + return forms; +} + +/** + * The identity each admitted spelling of one entry runs under. + * + * Read off the definition the entry already holds, because the declaration that + * owns the implementation is what states it. Two refusals, both about the host + * table rather than about the fragment: a spelling its owner offers no + * generated use of has no identity for a continuation to be compared against, + * and a spelling offered as a read cannot be admitted in the write table — + * what a spelling does belongs to the component, so a table placing it + * elsewhere would grant what the implementation does not do. + */ +function offeredIdentities( + name: string, + definition: FunctionComponentDefinition, + forms: readonly AuthoredForm[], + effect: GeneratedEffectClass, +): ReadonlyMap { + const identities = new Map(); + for (const form of forms) { + const offered = offeredForm(definition.generated, form); + if (offered === undefined) { + throw new GeneratedXmdError( + `a generated-XMD allowlist admitted <${name}> in its ${form} form, and the declaration ` + + "that owns that implementation offers no generated use of it.", + ); + } + if (offered.effect !== effect) { + throw new GeneratedXmdError( + `a generated-XMD allowlist admitted <${name}> in its ${form} form as a ${effect}, and ` + + `its owner offers that form as a ${offered.effect}.`, + ); + } + identities.set(form, offered.identity); + } + return identities; +} + /** * The pinned identities this fragment may name, keyed by the name it writes. * * A name may hold more than one entry, because a component whose two spellings - * do different things has two identities. What it may not hold is two entries - * that could both answer for one element, or two definitions: an import is - * asked for by name, so a name resolves to exactly one implementation and the - * form chooses only which identity that implementation ran as. Either - * ambiguity is a malformed host table, refused before anything is retained - * rather than settled by whichever entry was listed first. + * do different things offers each of them its own identity. What it may not + * hold is two entries that could both answer for one element, or two + * definitions: an import is asked for by name, so a name resolves to exactly + * one implementation and the form chooses only which identity that + * implementation ran as. Either ambiguity is a malformed host table, refused + * before anything is retained rather than settled by whichever entry was listed + * first. */ function admitted(entries: readonly Entry[]): Map { const table = new Map(); @@ -1276,8 +1199,8 @@ function sameStrings(one: readonly string[], other: readonly string[]): boolean * 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(); +function* normalizedCeilings(entries: readonly Entry[]): Operation { + const ceilings = new Map(); for (const entry of entries) { if (entry.requests === undefined) { continue; @@ -1286,33 +1209,47 @@ function* normalizedCeilings(entries: readonly Entry[]): Operation; + /** The ceilings this run states, in the order the host stated them. */ function currentPolicy( request: GeneratedXmdRequest, allow: readonly GeneratedEffectClass[], entries: readonly Entry[], - ceilings: ReadonlyMap, + ceilings: Ceilings, ): Policy { const requests: FetchRequest[] = []; const allowed: RetainedEntry[] = []; for (const entry of entries) { - allowed.push({ - name: entry.name, - identity: entry.identity, - forms: entry.forms, - // Carried on the current policy so a version-1 record can be reconciled - // against it, and dropped by `policyRecord` so nothing this run writes - // holds it. - ...(entry.legacy === undefined ? {} : { legacy: entry.legacy }), - }); - requests.push(...(ceilings.get(identityKey(entry.identity)) ?? [])); + // One row per admitted spelling, in canonical form order within the entry, + // so the retained table says which spelling of which name was admitted as + // what rather than leaving the effect to be inferred from a position. + for (const form of AUTHORED_FORMS.filter((candidate) => entry.forms.includes(candidate))) { + const identity = entry.identities.get(form); + if (identity === undefined) { + throw new GeneratedXmdError( + `a generated-XMD allowlist admitted <${entry.name}> in its ${form} form with no ` + + "identity for it.", + ); + } + allowed.push({ + name: entry.name, + form, + identity, + kind: entry.kind, + effect: entry.effect, + }); + } + requests.push(...(ceilings.get(entry) ?? [])); } const workspace = workspaceBasis(request); return { @@ -1320,7 +1257,6 @@ function currentPolicy( ...(workspace === undefined ? {} : { workspace }), allowed, requests, - composition: entries.filter((entry) => entry.effect === "composition").length, }; } @@ -1364,14 +1300,14 @@ function workspaceBasis( } /** - * The policy as journal data, in the closed version-2 shape. - * - * Version 1 is the untagged #369 record, whose `roots` and `selectedRoot` sat - * at the top level and were mandatory. Version 2 tags itself and carries the - * Workspace basis as one optional member, because an ordinary host has none — - * and telling the two apart matters: 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. + * 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. */ function policyRecord(policy: Policy): JsonObject { return { @@ -1387,38 +1323,15 @@ function policyRecord(policy: Policy): JsonObject { }), allowed: policy.allowed.map((entry) => ({ name: entry.name, - identity: retainedIdentityRecord(entry.identity), - forms: [...entry.forms], + form: entry.form, + identity: entry.identity, + kind: entry.kind, + effect: entry.effect, })), requests: policy.requests.map(requestRecord), }; } -/** - * One identity as journal data. - * - * Total over both retained shapes, so a policy read back is written back as the - * shape it was read as. What this run *states* is always the version-2 record — - * the version-1 arm exists because a record read from an older journal is still - * a policy, not because this build writes one. - * - * Distinct from the one-line spelling a *diagnostic* uses - * (`components/component-resolution.ts`): that one is for a reader, and this one is - * what a continuation compares, so they are named apart rather than allowed to - * drift into each other. - */ -function retainedIdentityRecord(identity: RetainedIdentity): Json { - if ("legacy" in identity) { - return identity.legacy; - } - return { - kind: identity.kind, - origin: identity.origin, - key: identity.key, - revision: identity.revision, - }; -} - /** * The policy a record holds, parsed rather than trusted. * @@ -1449,65 +1362,39 @@ function exactly( } /** - * The policy a record holds, in whichever closed shape its version defines. - * - * The two versions are exact and disjoint. Version 1 is the untagged #369 - * record: no version member, the Workspace basis as two mandatory top-level - * ones. Version 2 tags itself, carries the basis as one optional member, and - * has no legacy root fields at all — so a record mixing the two, or tagging - * itself with a version this build does not have, is refused rather than read - * as whichever it most resembles. + * The policy a record holds, in the one closed shape this build has. + * + * Exact: the version tag has to be this build's, the members have to be these + * members, and nothing is read partially. A record from any other build — the + * untagged #369 one, a tagged one holding structural identity objects, one + * carrying an extra member — describes a shape whose rules this build does not + * know, so it is refused rather than read as whichever shape it most resembles. */ function readPolicy(value: Json): Policy | undefined { if (!isJsonObject(value)) { return undefined; } - const tagged = Object.hasOwn(value, "version"); - if (tagged) { - 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) { - return undefined; - } - return readPolicyTerms(value, workspace, 2); + if (value.version !== RECORD_VERSION) { + return undefined; } - if (!exactly(value, ["allow", "roots", "selectedRoot", "allowed", "requests"])) { + if (!exactly(value, ["version", "allow", "allowed", "requests"], ["workspace"])) { return undefined; } - // A version-1 record always evaluated against a Workspace, so its basis is - // mandatory and reads through the same validation a version-2 basis does. - const workspace = readWorkspace({ roots: value.roots, selectedRoot: value.selectedRoot }); - if (workspace === MALFORMED || workspace === undefined) { + const workspace = Object.hasOwn(value, "workspace") ? readWorkspace(value.workspace) : undefined; + if (workspace === MALFORMED) { return undefined; } - return readPolicyTerms(value, workspace, 1); -} - -/** The terms both versions share, read closed. */ -function readPolicyTerms( - value: JsonObject, - workspace: { roots: readonly string[]; selectedRoot: string } | undefined, - version: 1 | 2, -): Policy | undefined { const classes = readClasses(value.allow); - const identities = readAllowed(value.allowed, version); + const allowed = readAllowed(value.allowed); const requests = readRequests(value.requests); - if (classes === undefined || identities === undefined || requests === undefined) { + if (classes === undefined || allowed === undefined || requests === undefined) { return undefined; } return { allow: classes, ...(workspace === undefined ? {} : { workspace }), - allowed: identities, + allowed, requests, - recordVersion: version, }; } @@ -1613,76 +1500,55 @@ function readClasses(value: Json): GeneratedEffectClass[] | undefined { : undefined; } -/** The forms one entry is admitted for: a non-empty set, canonically ordered. */ -function readForms(value: Json): AuthoredForm[] | undefined { - if (!Array.isArray(value) || value.length === 0) { - return undefined; - } - const forms: AuthoredForm[] = []; - for (const form of value) { - const parsed = AUTHORED_FORMS.find((known) => known === form); - if (parsed === undefined || forms.includes(parsed)) { - return undefined; - } - forms.push(parsed); - } - return sameStrings( - forms, - AUTHORED_FORMS.filter((known) => forms.includes(known)), - ) - ? forms - : undefined; -} - /** - * One retained identity, in whichever shape its version defines. + * One retained identity: the URL string the spelling ran under. * - * The two are exact and disjoint. Version 1 is a string and nothing else. - * Version 2 is the closed tagged record and nothing else: a string, a missing - * member, an extra member, an unknown kind and a non-string member are each - * refused rather than read as whichever shape the value most resembles. + * A string in the accepted namespace and nothing else. An object — the + * structural identity an earlier build wrote — a relative reference, a URL + * somewhere else and an empty value are each refused rather than read as + * whichever shape the value most resembles. */ -function readIdentity(value: Json | undefined, version: 1 | 2): RetainedIdentity | undefined { - if (version === 1) { - return typeof value === "string" ? { legacy: value } : undefined; - } - if (!isJsonObject(value) || !exactly(value, ["kind", "origin", "key", "revision"])) { +function readIdentity(value: Json | undefined): string | undefined { + if (typeof value !== "string" || !isGeneratedIdentity(value)) { return undefined; } - const { origin, key, revision } = value; - const kind = IDENTITY_KINDS.find((known) => known === value.kind); - if ( - kind === undefined || - typeof origin !== "string" || - typeof key !== "string" || - typeof revision !== "string" - ) { - return undefined; - } - return { kind, origin, key, revision }; + return value; } -function readAllowed(value: Json, version: 1 | 2): RetainedEntry[] | undefined { +/** The effect table one row was admitted from. */ +function readEffect(value: Json | undefined): RetainedEntry["effect"] | undefined { + return value === "composition" ? "composition" : EFFECT_CLASSES.find((known) => known === value); +} + +function readAllowed(value: Json): RetainedEntry[] | undefined { if (!Array.isArray(value)) { return undefined; } - const identities: RetainedEntry[] = []; + const allowed: RetainedEntry[] = []; for (const entry of value) { - if (!isJsonObject(entry) || !exactly(entry, ["name", "identity", "forms"])) { + if (!isJsonObject(entry) || !exactly(entry, ["name", "form", "identity", "kind", "effect"])) { return undefined; } const { name } = entry; - const identity = readIdentity(entry.identity, version); - const forms = readForms(entry.forms); - if (typeof name !== "string" || identity === undefined || forms === undefined) { + const identity = readIdentity(entry.identity); + const form = AUTHORED_FORMS.find((known) => known === entry.form); + const kind = IMPLEMENTATIONS.find((known) => known === entry.kind); + const effect = readEffect(entry.effect); + if ( + typeof name !== "string" || + identity === undefined || + form === undefined || + kind === undefined || + effect === undefined + ) { return undefined; } - identities.push({ name, identity, forms }); + allowed.push({ name, form, identity, kind, effect }); } - return identities; + return allowed; } -function readNamed(value: Json, version: 1 | 2): RetainedInvocation[] | undefined { +function readNamed(value: Json): RetainedInvocation[] | undefined { if (!Array.isArray(value)) { return undefined; } @@ -1692,7 +1558,7 @@ function readNamed(value: Json, version: 1 | 2): RetainedInvocation[] | undefine return undefined; } const { name } = entry; - const identity = readIdentity(entry.identity, version); + const identity = readIdentity(entry.identity); const form = AUTHORED_FORMS.find((known) => known === entry.form); if (typeof name !== "string" || identity === undefined || form === undefined) { return undefined; @@ -1719,11 +1585,12 @@ function readNamed(value: Json, version: 1 | 2): RetainedInvocation[] | undefine * 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 name replaced, a - * form added, 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 precisely the one that comparing the *fragment* against - * the *current* policy would wave through. + * 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 + * 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)) { @@ -1732,27 +1599,12 @@ function policyHolds(retained: Policy, current: Policy): boolean { if (!sameStrings(retained.allow, current.allow)) { return false; } - // A version-1 record predates the trusted composition table, so it is - // reconciled against the effect entries alone. Composition grants nothing — - // its entries carry no effect operation — so a continuation held to the - // remainder is held to exactly the permissions it was admitted under. - const stated = - retained.recordVersion === 1 - ? current.allowed.slice(current.composition ?? 0) - : current.allowed; - if (retained.allowed.length !== stated.length) { + if (retained.allowed.length !== current.allowed.length) { return false; } const replaced = retained.allowed.some((entry, index) => { - const here = stated[index]; - return ( - here === undefined || - here.name !== entry.name || - // The retained side is what was granted; the current side is the entry - // asking to answer for it, and only that side states version-1 aliases. - !sameIdentity(entry.identity, here) || - !sameStrings(here.forms, entry.forms) - ); + const here = current.allowed[index]; + return here === undefined || !sameRow(entry, here); }); if (replaced) { return false; @@ -1815,7 +1667,7 @@ function* preflight( id: string, source: string, table: ReadonlyMap, - ceilings: 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 @@ -1869,7 +1721,7 @@ function* admitCandidateRequest(props: Record): Operation, - ceilings: ReadonlyMap, + ceilings: Ceilings, named: Planned[], scope: Set, lexical: Lexical = ROOT_LEXICAL, @@ -1932,13 +1784,20 @@ function* walk( throw new Refusal(form === "paired" ? "content" : "form"); } if (entry.requests !== undefined) { - const ceiling = ceilings.get(identityKey(entry.identity)) ?? []; + const ceiling = ceilings.get(entry) ?? []; const candidate = yield* admitCandidateRequest(segment.props); if (!ceiling.some((allowed) => sameRequest(allowed, candidate))) { throw new Refusal("request"); } } - named.push({ name: entry.name, identity: entry.identity, form, entry }); + // 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 `…`. + const identity = entry.identities.get(form); + if (identity === undefined) { + throw new Refusal("form"); + } + named.push({ name: entry.name, identity, form, entry }); // The children see this element's bindings but not its own `as`, and // nothing they bind escapes back to its siblings. They are a component // body rather than a transparent region, so the lexical facts reset: @@ -2042,7 +1901,7 @@ const NO_GENERATED_CONTEXT: ReadonlySet = new Set(["Content", "Output", function* structural( segment: ComponentElement, table: ReadonlyMap, - ceilings: ReadonlyMap, + ceilings: Ceilings, named: Planned[], scope: Set, lexical: Lexical, @@ -2224,7 +2083,7 @@ interface Alternative { function* alternatives( branches: readonly Alternative[], table: ReadonlyMap, - ceilings: ReadonlyMap, + ceilings: Ceilings, named: Planned[], scope: Set, lexical: Lexical, @@ -2261,7 +2120,7 @@ function* alternatives( function* transparent( segments: readonly Segment[], table: ReadonlyMap, - ceilings: ReadonlyMap, + ceilings: Ceilings, named: Planned[], scope: Set, lexical: Lexical, @@ -2288,15 +2147,16 @@ function refuseStructure( const GENERATED_XMD = "generated_xmd"; /** - * The record shape new executions write. - * - * Version 1 is the untagged #369 record and stays readable: a run suspended - * before this build resumes under exactly the ceilings it was admitted with. - * Version 2 tags itself, which is what lets an untagged record be recognized as - * the older shape rather than guessed at, and carries the Workspace basis as - * one optional member because an ordinary host has none. + * 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. */ -const RECORD_VERSION = 2; +const RECORD_VERSION = 3; /** * What the durable admission records for this source. @@ -2310,7 +2170,7 @@ function* admitSource( id: string, source: string, table: ReadonlyMap, - ceilings: ReadonlyMap, + ceilings: Ceilings, policy: Policy, ): Operation { try { @@ -2321,7 +2181,7 @@ function* admitSource( source, named: named.map((entry) => ({ name: entry.name, - identity: retainedIdentityRecord(entry.identity), + identity: entry.identity, form: entry.form, })), policy: policyRecord(policy), @@ -2348,7 +2208,7 @@ function* persistAdmission( id: string, source: string, table: ReadonlyMap, - ceilings: ReadonlyMap, + ceilings: Ceilings, policy: Policy, position: Readonly | undefined, ): Operation { @@ -2375,17 +2235,15 @@ function readAdmission(value: Json): RetainedAdmission | undefined { if (!isJsonObject(value)) { return undefined; } - // Tagged at the result level as well as inside the policy, and the two must - // agree: a record whose result claims one version and whose policy claims - // another describes no shape this build has. - const tagged = Object.hasOwn(value, "version"); - if (tagged && value.version !== RECORD_VERSION) { + // Marked at the result level as well as inside the policy, and both have to + // be this build's: a record missing either marker, or carrying another + // build's, describes no shape this one has. + if (value.version !== RECORD_VERSION) { return undefined; } - const version = tagged ? ["version"] : []; const { decision } = value; if (decision === "refused") { - if (!exactly(value, [...version, "decision", "construct"])) { + if (!exactly(value, ["version", "decision", "construct"])) { return undefined; } const { construct } = value; @@ -2396,24 +2254,18 @@ function readAdmission(value: Json): RetainedAdmission | undefined { if (decision !== "admitted") { return undefined; } - if (!exactly(value, [...version, "decision", "source", "named", "policy"])) { + if (!exactly(value, ["version", "decision", "source", "named", "policy"])) { return undefined; } const { source } = value; if (typeof source !== "string") { return undefined; } - const invocations = readNamed(value.named, tagged ? 2 : 1); + const invocations = readNamed(value.named); const retained = readPolicy(value.policy); if (invocations === undefined || retained === undefined) { return undefined; } - // The policy's own version has to be the one the result claimed. A tagged - // result holding an untagged policy, or the reverse, is two shapes at once. - const policyTagged = isJsonObject(value.policy) && Object.hasOwn(value.policy, "version"); - if (policyTagged !== tagged) { - return undefined; - } return { decision, source, named: invocations, policy: retained }; } diff --git a/packages/core/src/invocation-identity.ts b/packages/core/src/invocation-identity.ts index 1d8507dd1..1909b20eb 100644 --- a/packages/core/src/invocation-identity.ts +++ b/packages/core/src/invocation-identity.ts @@ -52,6 +52,8 @@ import { useScope } from "effection"; import type { Operation, Scope } from "effection"; import { printErrors, printsErrors } from "./component-failures.ts"; import { documentationOf } from "./components/documentation.ts"; +import { captureGenerated } from "./components/generated-metadata.ts"; +import type { GeneratedForms } from "./components/generated-metadata.ts"; import type { ComponentDocumentation } from "./components/documentation.ts"; import type { SyntaxReference } from "./syntax-reference.ts"; import type { EvaluationProfile } from "./evaluation-profile.ts"; @@ -147,6 +149,8 @@ export interface IdentityComponent extends ComponentDocumentation { readonly captures?: readonly string[]; /** The authored forms this component accepts. Omitted means both. */ readonly forms?: readonly InvocationForm[]; + /** The generated use each authored form is offered for, when it offers any. */ + readonly generated?: GeneratedForms; readonly origin: string; factory(claim: IdentityClaimant): FunctionComponent; } @@ -381,6 +385,8 @@ export interface ProtectedDeclaration { readonly returns?: ReturnsSchema; readonly captures?: readonly string[]; readonly forms?: readonly InvocationForm[]; + /** The generated use each authored form is offered for, when it offers any. */ + readonly generated?: GeneratedForms; readonly origin: string; build(claim: IdentityClaimant): ProtectedBody; } @@ -1027,6 +1033,7 @@ export interface IdentityRegistration extends ComponentDocumentation { readonly returns?: ReturnsSchema; readonly captures?: readonly string[]; readonly forms?: readonly InvocationForm[]; + readonly generated?: GeneratedForms; readonly fn: FunctionComponent; } @@ -1131,6 +1138,9 @@ export function installIdentities( ...(component.returns === undefined ? {} : { returns: component.returns }), ...(component.captures === undefined ? {} : { captures: component.captures }), ...(component.forms === undefined ? {} : { forms: component.forms }), + ...(component.generated === undefined + ? {} + : { generated: captureGenerated(component.generated) }), ...documentationOf(component), fn: implementation, }); @@ -1151,6 +1161,9 @@ export function installIdentities( ...(component.returns === undefined ? {} : { returns: component.returns }), ...(component.captures === undefined ? {} : { captures: component.captures }), ...(component.forms === undefined ? {} : { forms: component.forms }), + ...(component.generated === undefined + ? {} + : { generated: captureGenerated(component.generated) }), ...documentationOf(component), fn: implementation, }); @@ -1169,6 +1182,9 @@ export function installIdentities( ...(component.returns === undefined ? {} : { returns: component.returns }), ...(component.captures === undefined ? {} : { captures: component.captures }), ...(component.forms === undefined ? {} : { forms: component.forms }), + ...(component.generated === undefined + ? {} + : { generated: captureGenerated(component.generated) }), ...documentationOf(component), fn: implementation, }); diff --git a/packages/core/src/syntax-admitted.ts b/packages/core/src/syntax-admitted.ts index 999d8f75b..e7ab1cc9e 100644 --- a/packages/core/src/syntax-admitted.ts +++ b/packages/core/src/syntax-admitted.ts @@ -55,10 +55,10 @@ const OWNED_CAPABILITIES: Readonly>> * Core's documentation origin for this entry, when the entry is core's own * component however the host admitted it. * - * Documentation provenance only. The identity stays what the host stated, - * because that is what a continuation compares and what the grant was recorded - * as — a host's directory entry is still the host's grant even though the - * component behind the name is core's. + * 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. */ function ownedOrigin(entry: CapturedEntry): string | undefined { if (entry.protectedOrigin !== undefined) { @@ -71,12 +71,12 @@ function ownedOrigin(entry: CapturedEntry): string | undefined { /** * The symbols an evaluation admitted, in the order the tables state them. * - * The identity's origin is what each entry reports it came from, because that - * is what the host stated and what a continuation is compared against. 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. + * 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. */ export function admittedSymbols(entries: readonly CapturedEntry[]): SyntaxSymbols { return { @@ -97,7 +97,7 @@ function describe(entry: CapturedEntry): CompleteComponentSyntaxEntry { name: entry.name, origin: owned === undefined - ? { kind: "registered", origin: entry.identity.origin, reserved: false } + ? { kind: "registered", origin: entry.origin, reserved: false } : { kind: "protected", origin: owned }, sourceKind: owned === undefined ? "registered" : "protected", inspectability: "complete", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 66e4d45e5..f73c033d1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -10,6 +10,7 @@ import type { Json as DurableJson } from "@executablemd/durable-streams"; import type { TestHarnessComponentDefinition } from "./test-harness.ts"; import type { ComponentInvocation, InvocationForm } from "./invocation-identity.ts"; import type { ProtectedComponent } from "./components/protected.ts"; +import type { GeneratedForms } from "./components/generated-metadata.ts"; export type Json = DurableJson; @@ -287,6 +288,17 @@ export interface FunctionComponentDefinition { * declaration spelled where inspection can read it, so the two cannot drift. */ forms?: readonly InvocationForm[]; + /** + * The generated use this component's forms are offered for, per form. + * + * Absent offers none, which is what an ordinary component offers: a + * generated fragment names a component only where the declaration that owns + * the implementation said which identity and effect that spelling runs as + * (`components/generated-metadata.ts`). Carrying it is not authority — a + * table still admits the name, and canonical resolution still decides which + * definition answers for it. + */ + generated?: GeneratedForms; /** Prose about the component, for a reader. Never read by execution. */ description?: string; as?: string; diff --git a/packages/core/tests/all.test.ts b/packages/core/tests/all.test.ts index 5086a33c9..4d456d970 100644 --- a/packages/core/tests/all.test.ts +++ b/packages/core/tests/all.test.ts @@ -38,7 +38,6 @@ import type { ExecutionInstallation, GeneratedObservation, GeneratedXmdRequest, - RetainedFragmentIdentity, } from "../host.ts"; import { forEach } from "@effectionx/stream-helpers"; import { collect } from "../src/collect.ts"; @@ -1239,14 +1238,14 @@ describe("Tier ALL — PA8: and cannot cross a spawn", () => { const GENERATED_ROOT = "workflows/agent.md"; const GENERATED_SOURCE = "The host ran a generated fragment.\n"; -function hostIdentity(origin: string, key: string): RetainedFragmentIdentity { - return { kind: "component-answer", origin, key, revision: "1" }; -} - const GENERATED_PROBE: FunctionComponentDefinition = { kind: "function", name: "Probe", props: NO_PROPS, + forms: ["self-closing"], + generated: { + "self-closing": { identity: "https://executable.md/components/probe/read", effect: "read" }, + }, // deno-lint-ignore require-yield *fn() { return "probed"; @@ -1254,7 +1253,7 @@ const GENERATED_PROBE: FunctionComponentDefinition = { }; function generatedProbe(): GeneratedObservation { - return pinnedComponent("Probe", hostIdentity("test://probe", "Probe"), GENERATED_PROBE); + return pinnedComponent("Probe", GENERATED_PROBE, "component-answer"); } function generatedRequest(source: string): GeneratedXmdRequest { diff --git a/packages/core/tests/answer-identity.test.ts b/packages/core/tests/answer-identity.test.ts index 38f374b9f..a1f9643a7 100644 --- a/packages/core/tests/answer-identity.test.ts +++ b/packages/core/tests/answer-identity.test.ts @@ -1,27 +1,26 @@ /** - * Tier CIV — what a stated identity is bound to, and when it stops meaning + * Tier CIV — what a published answer is bound to, and when it stops meaning * anything. * * A generated fragment's admission is a decision about which implementation may * run. A function carries no identity a run can retain — comparing one compares - * how somebody wrote their code — so a provider states one. Everything here is - * about the ways a stated identity could be weaker than it looks: outliving the - * execution that minted it, outliving the *invocation* that made it, surviving - * the answer being replaced or edited, or being overwritten by a second - * provider. + * how somebody wrote their code — so a provider publishes the definition, and + * what each of its forms runs as is declared on it. Everything here is about + * the ways a publication could be weaker than it looks: outliving the execution + * that minted it, outliving the *invocation* that made it, surviving the answer + * being replaced or edited, or being overwritten by a second provider. * * The shape under test is the split between two separate rights. A provider * installation is the right to be asked, and it is deliberately reusable: one * installation answers several admitted names and the same name resolved more * than once. A request is the right to answer one asking, and it is closed the - * moment that handler invocation ends. Only a request records an identity, - * which is what makes a statement provable — an installation handle can prove - * "some provider this host installed" and nothing about which invocation is - * speaking. + * moment that handler invocation ends. Only a request publishes, which is what + * makes a publication provable — an installation handle can prove "some + * provider this host installed" and nothing about which invocation is speaking. * - * The rows read what `identify()` answers rather than what a refusal says: an - * identity statement that reported correctly while still identifying a - * substituted object would satisfy an error-shape assertion and none of these. + * The rows read what `identify()` answers rather than what a refusal says: a + * publication that reported correctly while still vouching for a substituted + * object would satisfy an error-shape assertion and none of these. * * The owner is `CanonicalImports`, which already holds issuance and retention. * One owner asks one question about one table; a second registry would be a @@ -33,24 +32,21 @@ import { expect } from "@executablemd/test-support/expect"; import { scoped } from "effection"; import type { Operation } from "effection"; -import { - AnswerIdentityError, - CanonicalImports, - identityRecord, -} from "../src/components/component-resolution.ts"; +import { AnswerIdentityError, CanonicalImports } from "../src/components/component-resolution.ts"; import type { - AnswerIdentity, ComponentAnswerRequest, ImportedDefinition, ProviderInstallation, ResolutionWindow, } from "../src/components/component-resolution.ts"; -import type { Json } from "../src/types.ts"; +import type { GeneratedForms } from "../src/components/generated-metadata.ts"; +import type { FunctionComponentDefinition, Json } from "../src/types.ts"; const ORIGIN = "test://provider"; -const IDENTITY: AnswerIdentity = { origin: ORIGIN, key: "Open", revision: "1" }; -const OPEN = { key: "Open", revision: "1" } as const; -const OTHER = { key: "Other", revision: "1" } as const; +/** What this provider's definition offers its self-closing form as. */ +const OFFERED: GeneratedForms = { + "self-closing": { identity: "https://executable.md/components/open/read", effect: "read" }, +}; /** One owner, activated the way canonical execution activates it. */ function owner(): CanonicalImports { @@ -99,12 +95,24 @@ function asking( } } -/** One answer a provider might return, fresh each time. */ +/** + * One answer a provider might return, fresh each time. + * + * It carries the generated metadata a provider's own declaration states, since + * that is what a publication is now a statement about: the identity and effect + * of each form travel on the definition rather than beside it. + */ function answer(name = "Open"): ImportedDefinition { + return { ...bare(name), generated: OFFERED }; +} + +/** One answer offering no generated use of itself, which is the ordinary case. */ +function bare(name = "Open"): FunctionComponentDefinition { return { kind: "function", name, props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], // deno-lint-ignore require-yield *fn(): Operation { return ""; @@ -112,6 +120,27 @@ function answer(name = "Open"): ImportedDefinition { }; } +/** + * One answer carrying metadata this engine may not be able to read. + * + * Assembled rather than declared, because the point of each of these is that a + * provider's definition crossed a package boundary carrying something the + * declared shape does not describe. + */ +function answerWith(generated: unknown): ImportedDefinition { + return Object.assign(bare(), { generated }); +} + +/** The generated identity on a retained definition, whichever arm it is. */ +function offeredIdentity(definition: ImportedDefinition | undefined): unknown { + const generated = definition === undefined ? undefined : Reflect.get(definition, "generated"); + if (typeof generated !== "object" || generated === null) { + return undefined; + } + const form = Reflect.get(generated, "self-closing"); + return typeof form === "object" && form !== null ? Reflect.get(form, "identity") : undefined; +} + /** The implementation on a definition, whichever arm of the union it is. */ function implementationOf(definition: ImportedDefinition | undefined): unknown { return definition === undefined ? undefined : (definition as { fn?: unknown }).fn; @@ -127,36 +156,42 @@ function refusalOf(attempt: () => unknown): unknown { } } -describe("Tier CIV — an identity belongs to one answer of one import", () => { - it("CIV23: an identity is stated on the exact answer and read back", function* () { +describe("Tier CIV — a publication belongs to one answer of one import", () => { + it("CIV23: a definition is published on the exact answer and read back", function* () { const imports = owner(); const provider = imports.provider(ORIGIN); const { resolution, supplied } = resolving(imports, "Open", (resolution) => ({ resolution, - supplied: asking(provider, "Open", (request) => request.claim(answer(), OPEN)), + supplied: asking(provider, "Open", (request) => request.answer(answer())), })); - expect(imports.identify(resolution, supplied)?.identity).toEqual(IDENTITY); - expect(identityRecord(IDENTITY)).toBe("test://provider#Open@1"); + const identified = imports.identify(resolution, supplied); + // The publishing installation's origin, which is provenance and nothing + // else: what the implementation *is* comes back on the definition, as the + // identity its own declaration offers that form under. + expect(identified?.origin).toBe(ORIGIN); + expect(offeredIdentity(identified?.definition)).toBe( + "https://executable.md/components/open/read", + ); }); - it("CIV24: identification answers with the claim-time copy, not the answer", function* () { + it("CIV24: identification answers with the publication-time copy, not the answer", function* () { const imports = owner(); const provider = imports.provider(ORIGIN); const supplied = answer(); const resolution = resolving(imports, "Open", (resolution) => { - asking(provider, "Open", (request) => request.claim(supplied, OPEN)); + asking(provider, "Open", (request) => request.answer(supplied)); return resolution; }); // One call answers both halves. The definition is core's own copy, taken - // when the claim was recorded — so a caller that keeps what identification + // when it was published — so a caller that keeps what identification // gave it has kept the object the check was made about, and never has to // read the chain's object a second time to obtain one. const identified = imports.identify(resolution, supplied); - expect(identified?.identity).toEqual(IDENTITY); + expect(identified?.origin).toBe(ORIGIN); expect(identified?.definition).not.toBe(supplied); expect(identified?.definition.name).toBe("Open"); // The implementation crosses by reference, because a function is not @@ -170,8 +205,9 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { const honest = answer(); const reads: string[] = []; // The check/use gap, planted. Reading a member through `[[Get]]` alternates - // between what was claimed and a substitution; comparing descriptors, which - // is how the claim is checked, does not run this at all. So a caller that + // between what was published and a substitution; comparing descriptors, + // which is how a publication is checked, does not run this at all. So a + // caller that // checked the descriptors and then *read* the object again to keep a copy // would keep the substitution, and this row is what says nothing does. const alternating = new Proxy(honest, { @@ -185,14 +221,14 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { }) as ImportedDefinition; const resolution = resolving(imports, "Open", (resolution) => { - asking(provider, "Open", (request) => request.claim(alternating, OPEN)); + asking(provider, "Open", (request) => request.answer(alternating)); return resolution; }); const identified = imports.identify(resolution, alternating); - // The claim itself was recorded from the first reading, and identification - // answers with that recording. - expect(identified?.identity).toEqual(IDENTITY); + // The publication itself was recorded from the first reading, and + // identification answers with that recording. + expect(identified?.origin).toBe(ORIGIN); expect(identified?.definition.name).toBe("Open"); // The plant is live rather than inert: the very next read of the object the // chain returned says something else, which is what a second read would @@ -201,51 +237,46 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { expect(identified?.definition.name).toBe("Open"); }); - it("CIV23: restating exactly the same claim is idempotent", function* () { + it("CIV23: publishing exactly the same definition again is idempotent", function* () { const imports = owner(); const provider = imports.provider(ORIGIN); const supplied = answer(); const resolution = resolving(imports, "Open", (resolution) => { asking(provider, "Open", (request) => { - request.claim(supplied, OPEN); - // A provider installed twice states the same thing twice. That is not - // two providers disagreeing, and it is not a conflict. - expect(refusalOf(() => request.claim(supplied, OPEN))).toBe(undefined); + request.answer(supplied); + // A provider installed twice publishes the same thing twice. That is + // not two providers disagreeing, and it is not a conflict. + expect(refusalOf(() => request.answer(supplied))).toBe(undefined); }); return resolution; }); - expect(imports.identify(resolution, supplied)?.identity).toEqual(IDENTITY); + expect(imports.identify(resolution, supplied)?.origin).toBe(ORIGIN); }); - it("CIV23: a competing claim refuses and never overwrites the first", function* () { + it("CIV23: a competing publication refuses and never overwrites the first", function* () { const imports = owner(); const first = imports.provider(ORIGIN); const second = imports.provider("test://other"); const held = answer(); const resolution = resolving(imports, "Open", (resolution) => { - asking(first, "Open", (request) => request.claim(held, OPEN)); - // Another installation, asked in the same live resolution, cannot rename - // what the first stated. An overwrite would let a second provider take + asking(first, "Open", (request) => request.answer(held)); + // Another installation, asked in the same live resolution, cannot take + // what the first published. An overwrite would let a second provider take // the first's implementation. asking(second, "Open", (request) => { - expect(refusalOf(() => request.claim(held, OPEN))).toBeInstanceOf(AnswerIdentityError); + expect(refusalOf(() => request.answer(held))).toBeInstanceOf(AnswerIdentityError); }); - // And the first installation's *next* invocation cannot restate it - // differently either. + // And the first installation's *next* invocation cannot publish a + // different implementation for a resolution it has already answered. asking(first, "Open", (request) => { - expect( - refusalOf(() => request.claim(held, { key: "Other", revision: "1" })), - ).toBeInstanceOf(AnswerIdentityError); - expect(refusalOf(() => request.claim(held, { key: "Open", revision: "2" }))).toBeInstanceOf( - AnswerIdentityError, - ); + expect(refusalOf(() => request.answer(answer()))).toBeInstanceOf(AnswerIdentityError); }); return resolution; }); - expect(imports.identify(resolution, held)?.identity).toEqual(IDENTITY); + expect(imports.identify(resolution, held)?.origin).toBe(ORIGIN); }); it("CIV25: a request whose handler has returned states nothing", function* () { @@ -263,11 +294,11 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { asking(provider, "Open", (request) => { stale = request; }); - expect(refusalOf(() => stale?.claim(late, OPEN))).toBeInstanceOf(AnswerIdentityError); + expect(refusalOf(() => stale?.answer(late))).toBeInstanceOf(AnswerIdentityError); // The positive control in the same still-open window: a fresh invocation - // of the same installation claims, so the refusal above is about the + // of the same installation publishes, so the refusal above is about the // handler lease rather than about the window or the provider. - asking(provider, "Open", (request) => request.claim(answer(), OPEN)); + asking(provider, "Open", (request) => request.answer(answer())); return resolution; }); @@ -292,15 +323,15 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { // Resolution N+1 of the same name. The stale request names the right // component and belongs to an import that is over. - const { resolution, claimed } = resolving(imports, "Open", (resolution) => { - expect(refusalOf(() => stale?.claim(fresh, OPEN))).toBeInstanceOf(AnswerIdentityError); + const { resolution, published } = resolving(imports, "Open", (resolution) => { + expect(refusalOf(() => stale?.answer(fresh))).toBeInstanceOf(AnswerIdentityError); // The same installation is asked again and answers this resolution: the // installation is reusable, the request is not. return { resolution, - claimed: asking(provider, "Open", (request) => { + published: asking(provider, "Open", (request) => { requests.push(request); - return request.claim(answer(), OPEN); + return request.answer(answer()); }), }; }); @@ -308,10 +339,10 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { expect(requests).toHaveLength(2); expect(requests[0]).not.toBe(requests[1]); expect(imports.identify(resolution, fresh)).toBe(undefined); - expect(imports.identify(resolution, claimed)?.identity).toEqual(IDENTITY); + expect(imports.identify(resolution, published)?.origin).toBe(ORIGIN); }); - it("CIV25: a stale request cannot retag while another name is being decided", function* () { + it("CIV25: a stale request cannot publish while another name is being decided", function* () { const imports = owner(); const provider = imports.provider(ORIGIN); const substitute = answer("Other"); @@ -323,19 +354,19 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { }); }); - // `claim` takes no name, so the stale request cannot even ask about the + // `answer` takes no name, so the stale request cannot even ask about the // name being decided: it is fixed to `Open`, which this resolution is not. - const { resolution, claimed } = resolving(imports, "Other", (resolution) => { + const { resolution, published } = resolving(imports, "Other", (resolution) => { expect(stale?.name).toBe("Open"); - expect(refusalOf(() => stale?.claim(substitute, OTHER))).toBeInstanceOf(AnswerIdentityError); + expect(refusalOf(() => stale?.answer(substitute))).toBeInstanceOf(AnswerIdentityError); return { resolution, - claimed: asking(provider, "Other", (request) => request.claim(answer("Other"), OTHER)), + published: asking(provider, "Other", (request) => request.answer(answer("Other"))), }; }); expect(imports.identify(resolution, substitute)).toBe(undefined); - expect(imports.identify(resolution, claimed)?.identity.key).toBe("Other"); + expect(imports.identify(resolution, published)?.definition.name).toBe("Other"); }); it("CIV25: one installation answers two names through two distinct requests", function* () { @@ -348,16 +379,16 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { const open = resolving(imports, "Open", (resolution) => ({ resolution, - claimed: asking(provider, "Open", (request) => { + published: asking(provider, "Open", (request) => { seen.push(request); - return request.claim(answer(), OPEN); + return request.answer(answer()); }), })); const other = resolving(imports, "Other", (resolution) => ({ resolution, - claimed: asking(provider, "Other", (request) => { + published: asking(provider, "Other", (request) => { seen.push(request); - return request.claim(answer("Other"), OTHER); + return request.answer(answer("Other")); }), })); @@ -365,8 +396,8 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { expect(seen).toHaveLength(2); expect(seen[0]).not.toBe(seen[1]); expect(seen.map((request) => request.name)).toEqual(["Open", "Other"]); - expect(imports.identify(open.resolution, open.claimed)?.identity.key).toBe("Open"); - expect(imports.identify(other.resolution, other.claimed)?.identity.key).toBe("Other"); + expect(imports.identify(open.resolution, open.published)?.definition.name).toBe("Open"); + expect(imports.identify(other.resolution, other.published)?.definition.name).toBe("Other"); }); it("CIV25: identification takes the window it was asked about", function* () { @@ -376,16 +407,16 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { const first = resolving(imports, "Open", (resolution) => ({ resolution, - claimed: asking(provider, "Open", (request) => { + published: asking(provider, "Open", (request) => { requests.push(request); - return request.claim(answer(), OPEN); + return request.answer(answer()); }), })); const second = resolving(imports, "Open", (resolution) => ({ resolution, - claimed: asking(provider, "Open", (request) => { + published: asking(provider, "Open", (request) => { requests.push(request); - return request.claim(answer(), OPEN); + return request.answer(answer()); }), })); @@ -394,10 +425,10 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { // opened, which is why the answer does not change with when it is asked. expect(requests).toHaveLength(2); expect(requests[0]).not.toBe(requests[1]); - expect(imports.identify(first.resolution, first.claimed)?.identity).toEqual(IDENTITY); - expect(imports.identify(second.resolution, second.claimed)?.identity).toEqual(IDENTITY); - expect(imports.identify(first.resolution, second.claimed)).toBe(undefined); - expect(imports.identify(second.resolution, first.claimed)).toBe(undefined); + expect(imports.identify(first.resolution, first.published)?.origin).toBe(ORIGIN); + expect(imports.identify(second.resolution, second.published)?.origin).toBe(ORIGIN); + expect(imports.identify(first.resolution, second.published)).toBe(undefined); + expect(imports.identify(second.resolution, first.published)).toBe(undefined); }); it("CIV25: a request shows the asked position by value", function* () { @@ -429,38 +460,39 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { }); }); - it("CIV23: a different object carries no claim, however alike", function* () { + it("CIV23: a different object carries no publication, however alike", function* () { const imports = owner(); const provider = imports.provider(ORIGIN); - const { resolution, claimed } = resolving(imports, "Open", (resolution) => ({ + const { resolution, published } = resolving(imports, "Open", (resolution) => ({ resolution, - claimed: asking(provider, "Open", (request) => request.claim(answer(), OPEN)), + published: asking(provider, "Open", (request) => request.answer(answer())), })); - expect(imports.identify(resolution, claimed)?.identity).toEqual(IDENTITY); + expect(imports.identify(resolution, published)?.origin).toBe(ORIGIN); // The outer-replacement case: a handler further out returns its own object, - // so an intermediate claim does not travel with the name. + // so an intermediate publication does not travel with the name. expect(imports.identify(resolution, answer())).toBe(undefined); - // A copy of the claimed object is a different object too. - expect(imports.identify(resolution, { ...claimed })).toBe(undefined); + // A copy of the published object is a different object too, and copying the + // generated metadata with it changes nothing. + expect(imports.identify(resolution, { ...published })).toBe(undefined); }); - it("CIV23: editing the claimed answer invalidates the claim", function* () { + it("CIV23: editing the published answer invalidates the publication", function* () { const imports = owner(); const provider = imports.provider(ORIGIN); - const { resolution, claimed } = resolving(imports, "Open", (resolution) => ({ + const { resolution, published } = resolving(imports, "Open", (resolution) => ({ resolution, - claimed: asking(provider, "Open", (request) => request.claim(answer(), OPEN)), + published: asking(provider, "Open", (request) => request.answer(answer())), })); - expect(imports.identify(resolution, claimed)?.identity).toEqual(IDENTITY); + expect(imports.identify(resolution, published)?.origin).toBe(ORIGIN); - // The same object, edited after the claim by a handler further out. What - // was claimed is no longer what is there. - (claimed as { name: string }).name = "Substituted"; + // The same object, edited after publication by a handler further out. What + // was published is no longer what is there. + (published as { name: string }).name = "Substituted"; - expect(imports.identify(resolution, claimed)).toBe(undefined); + expect(imports.identify(resolution, published)).toBe(undefined); }); it("CIV25: an installation retained past its execution opens nothing", function* () { @@ -468,34 +500,34 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { const provider = imports.provider(ORIGIN); const { resolution, before } = resolving(imports, "Open", (resolution) => ({ resolution, - before: asking(provider, "Open", (request) => request.claim(answer(), OPEN)), + before: asking(provider, "Open", (request) => request.answer(answer())), })); - expect(imports.identify(resolution, before)?.identity).toEqual(IDENTITY); + expect(imports.identify(resolution, before)?.origin).toBe(ORIGIN); imports.revoke(); // The installation is the object a provider kept. The window it would need - // cannot be opened, and a request minted from it states nothing. + // cannot be opened, and a request minted from it publishes nothing. expect(refusalOf(() => imports.beginResolution("Open"))).toBeInstanceOf(AnswerIdentityError); expect( - refusalOf(() => asking(provider, "Open", (request) => request.claim(answer(), OPEN))), + refusalOf(() => asking(provider, "Open", (request) => request.answer(answer()))), ).toBeInstanceOf(AnswerIdentityError); - // And what it stated while the execution was live identifies nothing now: + // And what it published while the execution was live identifies nothing now: // an admission may not be reconciled against a run that is over. expect(imports.identify(resolution, before)).toBe(undefined); expect(imports.identifying).toBe(false); }); - it("CIV25: an owner starts inactive, so a claim before activation refuses", function* () { + it("CIV25: an owner starts inactive, so a publication before activation refuses", function* () { const imports = new CanonicalImports(); // Canonical execution registers teardown, then activates, then installs - // providers. A claim that landed before activation would be a claim with no - // teardown behind it. + // providers. A publication that landed before activation would be one with + // no teardown behind it. expect(imports.identifying).toBe(false); expect(refusalOf(() => imports.beginResolution("Open"))).toBeInstanceOf(AnswerIdentityError); expect( refusalOf(() => - asking(imports.provider(ORIGIN), "Open", (request) => request.claim(answer(), OPEN)), + asking(imports.provider(ORIGIN), "Open", (request) => request.answer(answer())), ), ).toBeInstanceOf(AnswerIdentityError); }); @@ -506,74 +538,92 @@ describe("Tier CIV — an identity belongs to one answer of one import", () => { const shared = answer(); const one = resolving(first, "Open", (resolution) => { - asking(first.provider(ORIGIN), "Open", (request) => request.claim(shared, OPEN)); + asking(first.provider(ORIGIN), "Open", (request) => request.answer(shared)); return resolution; }); // Live at the same time, and each answers only for what it recorded. const two = resolving(second, "Open", (resolution) => { expect(second.identify(resolution, shared)).toBe(undefined); - asking(second.provider(ORIGIN), "Open", (request) => - request.claim(shared, { key: "Open", revision: "2" }), - ); + asking(second.provider("test://other"), "Open", (request) => request.answer(shared)); return resolution; }); - expect(first.identify(one, shared)?.identity).toEqual(IDENTITY); - expect(second.identify(two, shared)?.identity).toEqual({ - origin: ORIGIN, - key: "Open", - revision: "2", - }); + // One definition, published separately in each owner, and each owner + // answers with the provenance of the installation that published it there. + expect(first.identify(one, shared)?.origin).toBe(ORIGIN); + expect(second.identify(two, shared)?.origin).toBe("test://other"); // The positive control: tearing one down leaves the other working. first.revoke(); expect(first.identify(one, shared)).toBe(undefined); - expect(second.identify(two, shared)?.identity.revision).toBe("2"); + expect(second.identify(two, shared)?.origin).toBe("test://other"); }); it("CIV23: a provider cannot state an origin canonical execution did not give it", function* () { const imports = owner(); - // The installation carries the origin; the provider states only key and - // revision. There is no member on the claim call to put another origin in. - const { resolution, claimed } = resolving(imports, "Open", (resolution) => ({ + // The installation carries the origin, and `answer` takes the definition + // and nothing else. There is no member on the call to put another origin + // in, and the provenance identification reports is the one this execution + // assigned. + const { resolution, published } = resolving(imports, "Open", (resolution) => ({ resolution, - claimed: asking(imports.provider("test://assigned"), "Open", (request) => - request.claim(answer(), OPEN), + published: asking(imports.provider("test://assigned"), "Open", (request) => + request.answer(answer()), ), })); - expect(imports.identify(resolution, claimed)?.identity.origin).toBe("test://assigned"); + expect(imports.identify(resolution, published)?.origin).toBe("test://assigned"); }); - it("CIV23: a partial identity is refused rather than recorded", function* () { + it("CIV23: metadata this engine cannot read is refused rather than recorded", function* () { const imports = owner(); const provider = imports.provider(ORIGIN); - const attempts = [ - { key: "", revision: "1" }, - { key: "Open", revision: "" }, + // A provider's definition never passed registration, so its generated + // metadata is read here or nowhere. Each of these is a form offered under + // something a continuation could not be compared against. + const attempts: unknown[] = [ + { "self-closing": { identity: "components/open/read", effect: "read" } }, + { "self-closing": { identity: "https://executable.md.example.com/open", effect: "read" } }, + { "self-closing": { identity: "https://executable.md/components/open/read", effect: "ask" } }, + { paired: { identity: "https://executable.md/components/open/write", effect: "write" } }, ]; - for (const attempt of attempts) { - const supplied = answer(); + for (const generated of attempts) { + const supplied = answerWith(generated); const resolution = resolving(imports, "Open", (resolution) => { asking(provider, "Open", (request) => { - expect(refusalOf(() => request.claim(supplied, attempt))).toBeInstanceOf( - AnswerIdentityError, - ); + expect(refusalOf(() => request.answer(supplied))).toBeInstanceOf(AnswerIdentityError); }); return resolution; }); - // Refused rather than partially recorded: a half identity would compare - // equal to a different half identity. + // Refused rather than partially recorded: metadata read in part would + // admit a form under an identity nobody declared. expect(imports.identify(resolution, supplied)).toBe(undefined); } }); - it("CIV23: an answer nobody claimed identifies nothing", function* () { + it("CIV23: an answer carrying no metadata is published and read back", function* () { + const imports = owner(); + const provider = imports.provider(ORIGIN); + // The ordinary authored case: a provider answering an import that is not a + // generated admission offers no generated form, and publishing it is still + // a perfectly good answer. What it cannot do is satisfy an admitted entry, + // which refuses the missing form where the table is sealed. + const { resolution, supplied } = resolving(imports, "Open", (resolution) => ({ + resolution, + supplied: asking(provider, "Open", (request) => request.answer(bare())), + })); + + expect(imports.identify(resolution, supplied)?.origin).toBe(ORIGIN); + expect(offeredIdentity(imports.identify(resolution, supplied)?.definition)).toBe(undefined); + }); + + it("CIV23: an answer nobody published identifies nothing", function* () { const imports = owner(); - // The ordinary case, and the reason this is not a permission: an unidentified - // answer is a perfectly good answer. What it cannot be is the thing a - // fragment runs, because a continuation would have nothing to compare. + // The ordinary case, and the reason this is not a permission: an answer + // nobody published is a perfectly good answer. What it cannot be is the + // thing a fragment runs, because a continuation would have nothing to + // compare. resolving(imports, "Open", (resolution) => { expect(imports.identify(resolution, answer())).toBe(undefined); expect(imports.identify(resolution, undefined)).toBe(undefined); @@ -611,12 +661,10 @@ describe("Tier PA10b — one serial resolution window", () => { first.close(); const second = imports.beginResolution("Open"); try { - expect(refusalOf(() => kept.request.claim(answer(), OPEN))).toBeInstanceOf( - AnswerIdentityError, - ); - // And the window that is open answers for what was claimed in it. - const claimed = asking(provider, "Open", (request) => request.claim(answer(), OPEN)); - expect(imports.identify(second, claimed)?.identity).toEqual(IDENTITY); + expect(refusalOf(() => kept.request.answer(answer()))).toBeInstanceOf(AnswerIdentityError); + // And the window that is open answers for what was published in it. + const published = asking(provider, "Open", (request) => request.answer(answer())); + expect(imports.identify(second, published)?.origin).toBe(ORIGIN); } finally { kept.close(); second.close(); @@ -632,10 +680,10 @@ describe("Tier PA10b — one serial resolution window", () => { const window = imports.beginResolution("Open"); try { - const claimed = yield* scoped(function* (): Operation { - return asking(provider, "Open", (request) => request.claim(answer(), OPEN)); + const published = yield* scoped(function* (): Operation { + return asking(provider, "Open", (request) => request.answer(answer())); }); - expect(imports.identify(window, claimed)?.identity).toEqual(IDENTITY); + expect(imports.identify(window, published)?.origin).toBe(ORIGIN); } finally { window.close(); } @@ -652,7 +700,7 @@ describe("Tier PA10b — one serial resolution window", () => { window.close(); expect( - refusalOf(() => asking(provider, "Open", (request) => request.claim(answer(), OPEN))), + refusalOf(() => asking(provider, "Open", (request) => request.answer(answer()))), ).toBeInstanceOf(AnswerIdentityError); expect(imports.identify(window, answer())).toBe(undefined); }); @@ -667,9 +715,9 @@ describe("Tier PA10c — one definition answering more than one import", () => { const shared = answer(); const first = resolving(imports, "Open", (resolution) => { - const supplied = asking(provider, "Open", (request) => request.claim(shared, OPEN)); + const supplied = asking(provider, "Open", (request) => request.answer(shared)); expect(supplied).toBe(shared); - expect(imports.identify(resolution, shared)?.identity).toEqual(IDENTITY); + expect(imports.identify(resolution, shared)?.origin).toBe(ORIGIN); return resolution; }); @@ -677,9 +725,9 @@ describe("Tier PA10c — one definition answering more than one import", () => { // over. The definition is the same object, and it is still this provider's // answer to say. resolvingLater(imports, provider, shared); - // And the closed window still reads the claim it recorded, unchanged by the + // And the closed window still reads the publication it recorded, unchanged by the // later one: each resolution keeps its own. - expect(imports.identify(first, shared)?.identity).toEqual(IDENTITY); + expect(imports.identify(first, shared)?.origin).toBe(ORIGIN); }); it("PA10c: reuse cannot substitute between comparison and retention", function* () { @@ -704,46 +752,46 @@ describe("Tier PA10c — one definition answering more than one import", () => { }); const first = resolving(imports, "Open", (resolution) => { - asking(provider, "Open", (request) => request.claim(shared, OPEN)); + asking(provider, "Open", (request) => request.answer(shared)); return resolution; }); expect(imports.identify(first, shared)?.definition.name).toBe("Open"); resolving(imports, "Open", (resolution) => { asking(provider, "Open", (request) => { - expect(refusalOf(() => request.claim(shared, OPEN))).toBeInstanceOf(AnswerIdentityError); + expect(refusalOf(() => request.answer(shared))).toBeInstanceOf(AnswerIdentityError); return undefined; }); expect(imports.identify(resolution, shared)).toBe(undefined); }); }); - it("PA10c: an edited definition cannot be claimed again, and identifies nowhere", function* () { + it("PA10c: an edited definition cannot be published again, and identifies nowhere", function* () { const imports = owner(); const provider = imports.provider(ORIGIN); const shared = answer(); const first = resolving(imports, "Open", (resolution) => { - asking(provider, "Open", (request) => request.claim(shared, OPEN)); - expect(imports.identify(resolution, shared)?.identity).toEqual(IDENTITY); + asking(provider, "Open", (request) => request.answer(shared)); + expect(imports.identify(resolution, shared)?.origin).toBe(ORIGIN); return resolution; }); - // One member of the definition, edited after it was claimed and while the - // stated identity says nothing changed. This is a different implementation - // wearing the first one's revision. + // One member of the definition, edited after it was published. This is a + // different implementation asking to answer under the first one's + // publication. shared.props = { type: "object", properties: {}, additionalProperties: true }; resolving(imports, "Open", (resolution) => { asking(provider, "Open", (request) => { - // Refused before anything is recorded: the identity was stated about - // the definition core retained, and this object is no longer it. - expect(refusalOf(() => request.claim(shared, OPEN))).toBeInstanceOf(AnswerIdentityError); + // Refused before anything is recorded: the publication was about the + // definition core retained, and this object is no longer it. + expect(refusalOf(() => request.answer(shared))).toBeInstanceOf(AnswerIdentityError); return undefined; }); // So the new window vouches for nothing. expect(imports.identify(resolution, shared)).toBe(undefined); }); - // And the window that did claim it cannot identify the changed object + // And the window that did publish it cannot identify the changed object // either, which is the change detection that was already there. expect(imports.identify(first, shared)).toBe(undefined); }); @@ -758,18 +806,18 @@ describe("Tier PA10c — one definition answering more than one import", () => { const shared = answer(); const first = imports.beginResolution("Open"); - asking(provider, "Open", (request) => request.claim(shared, OPEN)); - expect(imports.identify(first, shared)?.identity).toEqual(IDENTITY); + asking(provider, "Open", (request) => request.answer(shared)); + expect(imports.identify(first, shared)?.origin).toBe(ORIGIN); first.close(); const second = imports.beginResolution("Open"); try { - asking(provider, "Open", (request) => request.claim(shared, OPEN)); - expect(imports.identify(second, shared)?.identity).toEqual(IDENTITY); + asking(provider, "Open", (request) => request.answer(shared)); + expect(imports.identify(second, shared)?.origin).toBe(ORIGIN); // And each window keeps its own copy: the first still identifies what it - // claimed, because provenance is a fact about that resolution rather than + // published, because provenance is a fact about that resolution rather than // about which one is open now. - expect(imports.identify(first, shared)?.identity).toEqual(IDENTITY); + expect(imports.identify(first, shared)?.origin).toBe(ORIGIN); } finally { second.close(); } @@ -785,11 +833,11 @@ describe("Tier PA10c — one definition answering more than one import", () => { // that same window is two answers to one import, and that still refuses. resolving(imports, "Open", (resolution) => { asking(provider, "Open", (request) => { - expect(request.claim(shared, OPEN)).toBe(shared); - expect(refusalOf(() => request.claim(answer(), OPEN))).toBeInstanceOf(AnswerIdentityError); + expect(request.answer(shared)).toBe(shared); + expect(refusalOf(() => request.answer(answer()))).toBeInstanceOf(AnswerIdentityError); return undefined; }); - expect(imports.identify(resolution, shared)?.identity).toEqual(IDENTITY); + expect(imports.identify(resolution, shared)?.origin).toBe(ORIGIN); }); }); @@ -800,18 +848,19 @@ describe("Tier PA10c — one definition answering more than one import", () => { const shared = answer(); resolvingLater(imports, provider, shared); - // What this object is was settled by its first claim. A new resolution is a - // new question about which import it answered, never a chance to restate it. + // What this object answers for was settled by its first publication. A new + // resolution is a new question about which import it answered, never a + // chance to publish it again elsewhere. resolving(imports, "Other", (resolution) => { asking(provider, "Other", (request) => { - expect(refusalOf(() => request.claim(shared, OTHER))).toBeInstanceOf(AnswerIdentityError); + expect(refusalOf(() => request.answer(shared))).toBeInstanceOf(AnswerIdentityError); return undefined; }); expect(imports.identify(resolution, shared)).toBe(undefined); }); resolving(imports, "Open", (resolution) => { asking(other, "Open", (request) => { - expect(refusalOf(() => request.claim(shared, OPEN))).toBeInstanceOf(AnswerIdentityError); + expect(refusalOf(() => request.answer(shared))).toBeInstanceOf(AnswerIdentityError); return undefined; }); expect(imports.identify(resolution, shared)).toBe(undefined); @@ -833,8 +882,8 @@ function resolvingLater( shared: ImportedDefinition, ): void { resolving(imports, "Open", (resolution) => { - const supplied = asking(provider, "Open", (request) => request.claim(shared, OPEN)); + const supplied = asking(provider, "Open", (request) => request.answer(shared)); expect(supplied).toBe(shared); - expect(imports.identify(resolution, shared)?.identity).toEqual(IDENTITY); + expect(imports.identify(resolution, shared)?.origin).toBe(ORIGIN); }); } diff --git a/packages/core/tests/evaluate-component.test.ts b/packages/core/tests/evaluate-component.test.ts index 0239566d3..53de85bda 100644 --- a/packages/core/tests/evaluate-component.test.ts +++ b/packages/core/tests/evaluate-component.test.ts @@ -41,7 +41,7 @@ import type { ElicitationRequest } from "../src/elicitation-api.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 { answerProvider, implementation, offering } from "./support/answer-provider.ts"; import type { Implementation, ProviderOptions } from "./support/answer-provider.ts"; import type { FunctionComponentDefinition } from "../src/types.ts"; import { ActiveProjection } from "../src/projection.ts"; @@ -62,21 +62,12 @@ function syntaxProfile( options: { transform?: (answer: FunctionComponentDefinition) => FunctionComponentDefinition; retained?: FunctionComponentDefinition[]; - unclaimed?: boolean; - revision?: string; } = {}, ): ExecutionInstallation { let captured = false; return { evaluation: { - read: [ - { - kind: "component-answer", - name: "Syntax", - identity: { origin: "test://syntax-provider", key: "Syntax", revision: "1" }, - forms: ["self-closing"], - }, - ], + read: [{ kind: "component-answer", name: "Syntax", forms: ["self-closing"] }], }, componentAnswers: [ { @@ -90,10 +81,6 @@ function syntaxProfile( throw new Error("expected canonical Syntax"); } options.retained?.push(answer); - if (options.unclaimed) { - return answer; - } - request.claim(answer, { key: "Syntax", revision: options.revision ?? "1" }); return options.transform?.(answer) ?? answer; } return answer; @@ -120,7 +107,7 @@ describe("protected generated component answers", () => { throw new Error("expected canonical Syntax"); } const captured = yield* prepared.seal( - new Map([["Syntax", { definition }]]), + new Map([["Syntax", { definition, origin: "@executablemd/core" }]]), identity.componentRouting.project, ); const symbols = admittedSymbols(captured.read); @@ -170,16 +157,14 @@ describe("protected generated component answers", () => { * 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. The grant stays the - * host's: only the documentation origin is core's, which is why the host's own - * directory identity below is untouched. + * 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. */ it("reports an admitted file operation as the component canonical core owns", function* () { const prepared = yield* prepareEvaluationProfile({ read: [fileReadEntry()], - // Deliberately a host's own identity, and deliberately not core's: it is - // what a continuation compares, and nothing here may rewrite it. - write: [directoryEntry({ origin: "test://host", key: "Dir", revision: "1" }, "Dir")], + write: [directoryEntry("Dir")], }); try { const captured = yield* prepared.seal(new Map()); @@ -197,9 +182,12 @@ describe("protected generated component answers", () => { sourceKind: "protected", }, ]); - // The grant itself is unchanged. A journal holds this string, and the - // component moving into core did not move the host's entry with it. - expect(captured.write.map((entry) => entry.identity.origin)).toEqual(["test://host"]); + // And the grant is core's, because the component is: the paired spelling + // runs under the identity core's own declaration offers it as. + expect(captured.write.map((entry) => entry.origin)).toEqual(["@executablemd/core"]); + expect(captured.write[0]?.definition.generated).toMatchObject({ + paired: { identity: "https://executable.md/components/dir/ensure", effect: "write" }, + }); } finally { prepared.revoke(); } @@ -276,7 +264,7 @@ describe("protected generated component answers", () => { } }); - it("refuses copied, mutated, wrapped, unclaimed, misidentified and wrong-form answers", function* () { + it("refuses copied, mutated, wrapped and replaced answers, and a wrong form", function* () { const source = `'} />`; const ran: string[] = []; const variants: { @@ -287,7 +275,7 @@ describe("protected generated component answers", () => { { name: "copied", options: { transform: (answer) => ({ ...answer }) }, - reason: "carries no identity", + reason: "no provider published", }, { name: "replaced body", @@ -300,12 +288,12 @@ describe("protected generated component answers", () => { }, }), }, - reason: "carries no identity", + reason: "no provider published", }, { name: "wrapped", options: { transform: independentWrapper }, - reason: "carries no identity", + reason: "no provider published", }, { name: "mutated", @@ -315,13 +303,7 @@ describe("protected generated component answers", () => { return answer; }, }, - reason: "carries no identity", - }, - { name: "unclaimed", options: { unclaimed: true }, reason: "carries no identity" }, - { - name: "misidentified", - options: { revision: "2" }, - reason: "chain answered with test://syntax-provider#Syntax@2", + reason: "no provider published", }, ]; for (const { name, options, reason } of variants) { @@ -408,10 +390,7 @@ describe("protected generated component answers", () => { yield* refusal( run(source, [syntaxProfile({ transform: () => held })], new InMemoryStream(partial)), ), - ).toContain("carries no identity"); - expect( - yield* refusal(run(source, [syntaxProfile({ unclaimed: true })], new InMemoryStream(events))), - ).toContain("carries no identity"); + ).toContain("no provider published"); function inspect(value: unknown): void { expect(typeof value).not.toBe("function"); if (typeof value === "object" && value !== null) { @@ -444,11 +423,7 @@ function both(files: RecordedFiles): ExecutionInstallation { return { evaluation: { read: [fileReadEntry()], - write: [ - fileWriteEntry(), - directoryEntry({ origin: "test://host", key: "Dir", revision: "1" }, "Dir"), - fileDeleteEntry(), - ], + write: [fileWriteEntry(), directoryEntry("Dir"), fileDeleteEntry()], }, install: () => useRecordedFiles(files), }; @@ -1233,18 +1208,13 @@ describe("Tier FE — one occurrence, one durable decision", () => { ); const partial = complete.slice(0, admitted + 1); - // The same fragment, admitted under a table that now states another - // identity. A grant is the exact set it was made under. + // The same fragment, admitted under a read table that now admits a second + // component as well. A grant is the exact set of spellings it was made + // under, so a widened one is a different grant rather than the same one + // restated. const second = recordedFiles({ "notes.md": NOTE }); const widened: ExecutionInstallation = { - evaluation: { - read: [ - { - ...fileReadEntry(), - identity: { origin: "@executablemd/core", key: "File:read", revision: "99" }, - }, - ], - }, + evaluation: { read: [fileReadEntry(), globReadEntry()] }, install: () => useRecordedFiles(second), }; const failed = yield* refusal(run(source, [widened], new InMemoryStream(partial))); @@ -1888,7 +1858,7 @@ describe("Tier FE34 — the shared read profile", () => { expect(files.entries.get("notes.md")).toBe(NOTE); }); - it("FE34: canonical Syntax is the answer at core's identity, and a replacement loses it", function* () { + it("FE34: canonical core publishes the Syntax answer, and a replacement loses it", function* () { const files = recordedFiles({ "notes.md": NOTE }); const seen: FunctionComponentDefinition[] = []; const failed = yield* refusal( @@ -1907,11 +1877,11 @@ describe("Tier FE34 — the shared read profile", () => { }), ); - // Core states what is behind the protected name from the innermost + // Core publishes what is behind the protected name from the innermost // position, on the exact object materialization produced. A handler that - // returns anything else returns something carrying no identity at all. + // returns anything else returns something nobody published. expect(seen.length).toBeGreaterThan(0); - expect(failed).toContain("carries no identity"); + expect(failed).toContain("no provider published"); expect(files.performed).toEqual([]); }); @@ -2063,6 +2033,8 @@ describe("Tier FE34 — the shared read profile", () => { kind: "function", name: "Held", props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: offering("Held", "read"), *fn(): Operation { yield* ensure(function* () { throw new Error("the fragment's cleanup failed"); @@ -2076,12 +2048,7 @@ describe("Tier FE34 — the shared read profile", () => { fileReadEntry(), globReadEntry(), syntaxReadEntry(), - { - kind: "component-answer", - name: "Held", - identity: { origin: "test://provider", key: "Held", revision: "1" }, - forms: ["self-closing"], - }, + { kind: "component-answer", name: "Held", forms: ["self-closing"] }, ], }, install: () => useRecordedFiles(files), @@ -2125,31 +2092,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 at its identity", function* () { + it("FE34: core answers for Syntax alone, so Evaluate cannot be admitted as an answer", function* () { const files = recordedFiles({ "notes.md": NOTE }); const failed = yield* refusal( run(`\\n'} />\n`, [ { evaluation: { - read: [ - { - kind: "component-answer", - name: "Evaluate", - identity: { origin: "@executablemd/core", key: "Evaluate", revision: "2" }, - forms: ["self-closing"], - }, - ], + read: [{ kind: "component-answer", name: "Evaluate", forms: ["self-closing"] }], }, install: () => useRecordedFiles(files), }, ]), ); - // Stating an identity is what makes a protected name admissible, and core - // states one for the vocabulary and nothing else: evaluation inside + // 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 nothing identified. - expect(failed).toContain("carries no identity"); + // write. The hand-written entry resolves to an answer nobody published. + expect(failed).toContain("no provider published"); expect(files.performed).toEqual([]); }); }); @@ -2158,49 +2118,30 @@ describe("Tier FE34 — the shared read profile", () => { * Tier FE14 — an implementation the ordinary import chain answered for. * * A `component-answer` entry is the one arm of a profile where the host does - * not supply the body. It states a name and the exact structural identity a - * provider must have stated, and canonical execution resolves that name once — - * before the root import, through the complete ordinary chain — reads the - * identity off the exact final answer, compares it whole, and seals what it - * retained. + * not supply the body. It states a name and the forms it admits, and canonical + * execution resolves that name once — before the root import, through the + * complete ordinary chain — takes the definition the provider published on the + * exact final answer, holds each admitted spelling to what that definition + * offers, and seals what it retained. * * So the rows here are about the two ways that could be weaker than it looks: - * an answer nothing identified, and an answer that stopped being the thing that - * was identified. Each refusal names what did *not* happen — no admission, no + * an answer nobody published, and an answer that stopped being the thing that + * was published. Each refusal names what did *not* happen — no admission, no * body, no fragment effect — because a refusal that arrived after A ran would * satisfy an error-shape assertion and none of these. */ -describe("Tier FE14 — the chain answers, and the answer is held to its identity", () => { +describe("Tier FE14 — the chain answers, and the answer is held to what it offers", () => { const OPEN = `\\n'} as="answer" />\n\n\n`; /** The entry a host states for a provider-backed name. */ - function admits( - identity: { origin?: string; key?: string; revision?: string } = {}, - ): FragmentEvaluationInput { - return { - read: [ - { - kind: "component-answer", - name: "Open", - identity: { - origin: identity.origin ?? "test://provider", - key: identity.key ?? "Open", - revision: identity.revision ?? "1", - }, - forms: ["self-closing"], - }, - ], - }; + function admits(): FragmentEvaluationInput { + return { read: [{ kind: "component-answer", name: "Open", forms: ["self-closing"] }] }; } /** One installation admitting `` and backing it with one provider. */ - function backed( - answer: Implementation, - options: ProviderOptions = {}, - identity: { origin?: string; key?: string; revision?: string } = {}, - ): ExecutionInstallation { + function backed(answer: Implementation, options: ProviderOptions = {}): ExecutionInstallation { return { - evaluation: admits(identity), + evaluation: admits(), componentAnswers: [answerProvider("Open", answer.definition, options)], }; } @@ -2212,8 +2153,8 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit ); } - /** The identity one admission retained for the name it admitted. */ - function retainedIdentity(event: DurableEvent): Json { + /** The row one admission retained for the name it admitted. */ + function retainedRow(event: DurableEvent): Json { const result = event.type === "yield" ? event.result : undefined; if (result === undefined || result.status !== "ok" || !isRecord(result.value)) { throw new Error("the admission recorded no result"); @@ -2222,13 +2163,13 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit if (!isRecord(policy) || !Array.isArray(policy.allowed)) { throw new Error("the admission recorded no policy"); } - // The trusted composition table leads every policy, so the entry this row + // The trusted composition table leads every policy, so the row this case // is about is the first one the selection contributed. const entry = policy.allowed.find((held) => isRecord(held) && held.name !== "Json"); if (!isRecord(entry)) { throw new Error("the admission admitted nothing"); } - return entry.identity ?? null; + return entry; } function isRecord(value: Json | undefined): value is Record { @@ -2246,7 +2187,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit expect(A.invoked).toEqual(["A ran"]); }); - it("FE14: a fresh admission records the exact structural identity", function* () { + it("FE14: a fresh admission records the URL the admitted spelling runs under", function* () { const A = implementation("Open", "A ran"); const stream = new InMemoryStream(); yield* run(OPEN, [backed(A)], stream); @@ -2256,19 +2197,21 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit if (admission === undefined) { throw new Error("the run recorded no admission"); } - // The four members as themselves, and the kind among them: an answer the + // The spelling, the URL its owner offers it under, which effect table + // admitted it, and what kind of thing answered for the name: an answer the // chain resolved is a different grant from an operation core supplies the - // body for, even under the same origin, key and revision. - expect(retainedIdentity(admission)).toEqual({ + // body for, even under the same URL. + expect(retainedRow(admission)).toEqual({ + name: "Open", + form: "self-closing", + identity: "https://executable.md/components/open/read", kind: "component-answer", - origin: "test://provider", - key: "Open", - revision: "1", + effect: "read", }); expect(A.invoked).toEqual(["A ran"]); }); - it("FE14: an inner claim and an outer unclaimed answer refuse before any body", function* () { + it("FE14: an inner publication and an outer unpublished answer refuse before any body", function* () { const A = implementation("Open", "A ran"); const B = implementation("Open", "B ran"); const files = recordedFiles({ "notes.md": NOTE }); @@ -2276,9 +2219,9 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit const failed = yield* refusal( scoped(function* () { - // A handler further out returns its own object. The inner provider's - // claim was about the object it returned, and this is not that object — - // so nothing identifies the answer the chain finally gave. + // A handler further out returns its own object. The inner provider + // published the object it returned, and this is not that object — so + // nobody published the answer the chain finally gave. yield* Component.around({ *importComponent([name], next) { const definition = yield* next(name); @@ -2287,13 +2230,13 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit }); return yield* run( OPEN, - [{ ...backed(A), evaluation: admits({}), install: () => useRecordedFiles(files) }], + [{ ...backed(A), evaluation: admits(), install: () => useRecordedFiles(files) }], stream, ); }), ); - expect(failed).toContain("carries no identity"); + expect(failed).toContain("no provider published"); // Ahead of everything: no admission, neither body, no fragment effect. expect(admissions(yield* stream.readAll())).toHaveLength(0); expect(A.invoked).toEqual([]); @@ -2301,7 +2244,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit expect(files.performed).toEqual([]); }); - it("FE14: an inner claimed A and an outer claimed B compose, and only B runs", function* () { + it("FE14: an inner published A and an outer published B compose, and only B runs", function* () { const A = implementation("Open", "A ran"); const B = implementation("Open", "B ran"); const delegated: unknown[] = []; @@ -2310,29 +2253,29 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit // The positive control for the row above, and a real chain rather than one // provider. Two providers answer for one name: the one installed first // composes outermost, delegates — so the inner one genuinely answers and - // claims A — and then returns its own claimed B. The difference from the - // refusal above is only that the identity travels with the object the chain - // finally gives back, which is what makes the substitution honest. + // publishes A — and then returns its own published B. The difference from + // the refusal above is only that the publication travels with the object + // the chain finally gives back, which is what makes the substitution + // honest. const output = yield* run(OPEN, [ { - evaluation: admits({ key: "Outer" }), + evaluation: admits(), componentAnswers: [ answerProvider("Open", B.definition, { - key: "Outer", delegatesFirst: true, delegated, whileResolving(request) { outerRequests.push(request.name); }, }), - answerProvider("Open", A.definition, { key: "Inner" }), + answerProvider("Open", A.definition, {}), ], }, ]); // The inner half really answered: what delegation returned is the exact - // object the inner provider claimed. So A was resolvable, and the row below - // it is about which of two live answers runs rather than about one. + // object the inner provider published. So A was resolvable, and the row + // below it is about which of two live answers runs rather than about one. expect(delegated[0]).toBe(A.definition); expect(outerRequests).toEqual(["Open"]); expect(String(output)).toContain("B ran"); @@ -2342,7 +2285,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit it("FE14: an answer whose contract reads differently each time cannot substitute", function* () { const reads: string[] = []; - // The claimed contract accepts one prop; the substitution accepts none. + // The published contract accepts one prop; the substitution accepts none. const A = implementation("Open", "A ran", { type: "object", properties: { flag: { type: "string" } }, @@ -2350,10 +2293,10 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit }); // The check/use gap, planted. Reading the answer's schema alternates, and - // comparing own descriptors — which is how the claim is checked — does not - // run the trap. So the reading the capture *keeps* decides what a fragment - // is validated against: the claim-time schema admits `flag`, and the - // substitution refuses it. + // comparing own descriptors — which is how a publication is checked — does + // not run the trap. So the reading the capture *keeps* decides what a + // fragment is validated against: the publication-time schema admits `flag`, + // and the substitution refuses it. const output = yield* run(`\\n'} as="answer" />\n`, [ { evaluation: admits(), @@ -2405,21 +2348,21 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit expect(A.invoked).toEqual([]); }); - it("FE14: a provider still claiming when a fragment resolves is refused earlier", function* () { + it("FE14: a provider still publishing when a fragment resolves is refused earlier", function* () { const A = implementation("Open", "A ran"); const stream = new InMemoryStream(); // The same substitution, one step earlier. The resolution this provider - // answered settled during the capture, so there is no window left to state - // an identity into — the claim refuses before the witness is consulted, and - // an answer nothing could identify never reaches the fragment. + // answered settled during the capture, so there is no window left to + // publish into — the publication refuses before the witness is consulted, + // and an answer nobody could have published never reaches the fragment. const failed = yield* refusal( run( OPEN, [ { evaluation: admits(), - componentAnswers: [answerProvider("Open", A.definition, { reclaimsLater: true })], + componentAnswers: [answerProvider("Open", A.definition, { publishesLater: true })], }, ], stream, @@ -2430,11 +2373,11 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit expect(A.invoked).toEqual([]); }); - it("FE14: an unidentified, a copied and a mutated answer each refuse", function* () { + it("FE14: an unpublished, a copied and a mutated answer each refuse", function* () { const cases: readonly (readonly [string, ProviderOptions])[] = [ - ["nothing identified it", { unclaimed: true }], - ["the chain returned a copy of what was claimed", { copied: true }], - ["the claimed object was edited afterwards", { mutated: true }], + ["nobody published it", { unpublished: true }], + ["the chain returned a copy of what was published", { copied: true }], + ["the published object was edited afterwards", { mutated: true }], ]; for (const [what, options] of cases) { @@ -2446,7 +2389,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit OPEN, [ { - evaluation: admits({}), + evaluation: admits(), install: () => useRecordedFiles(files), componentAnswers: [answerProvider("Open", A.definition, options)], }, @@ -2455,7 +2398,7 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit ), ); - expect([what, failed.includes("carries no identity")]).toEqual([what, true]); + expect([what, failed.includes("no 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]); @@ -2464,29 +2407,30 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit } }); - it("FE14: an answer under another identity refuses before any body", function* () { - const A = implementation("Open", "A ran"); + it("FE14: an answer offering no generated use of the admitted spelling refuses", function* () { const files = recordedFiles(); const stream = new InMemoryStream(); + // The provider answers honestly, with a definition whose own declaration + // offers no generated use of the spelling this host admitted. There is no + // identity for a continuation to be compared against, so the entry refuses + // before the root import rather than running something unaccountable. + const A = implementation("Open", "A ran", undefined, null); const failed = yield* refusal( run( OPEN, [ { - evaluation: admits({ revision: "1" }), + evaluation: admits(), install: () => useRecordedFiles(files), - // The provider claims honestly — for a revision this host did not - // admit. An admitted identity is the exact implementation, and a - // changed revision is a changed grant. - componentAnswers: [answerProvider("Open", A.definition, { revision: "2" })], + componentAnswers: [answerProvider("Open", A.definition)], }, ], stream, ), ); - expect(failed).toContain("the exact implementation"); + expect(failed).toContain("offers no generated use of it"); expect(admissions(yield* stream.readAll())).toHaveLength(0); expect(A.invoked).toEqual([]); }); @@ -2503,27 +2447,24 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit const partial = complete.slice(0, admitted + 1); // The resumed run supplies both implementations, live. The inner provider - // answers the unchanged A under the identity the admission was made over; - // the outer one delegates to it and then answers with its own claimed B, - // under the identity this host now admits. So capture reconciles and seals - // B — and then the *retained* policy refuses, because the admission was - // made over `Open@1` and this run states `Other@1` behind the same name. + // answers the unchanged A, whose declaration offers the identity the + // admission was made over; the outer one delegates to it and then answers + // with its own published B, whose declaration offers that spelling under a + // different URL. So capture seals B — and then the *retained* policy + // refuses, because the admission was made over one URL and this run states + // another behind the same name. const resumedA = implementation("Open", "A ran"); - const B = implementation("Open", "B ran"); + const B = implementation("Open", "B ran", undefined, offering("Other", "read")); const delegated: unknown[] = []; const failed = yield* refusal( run( OPEN, [ { - evaluation: admits({ key: "Other" }), + evaluation: admits(), componentAnswers: [ - answerProvider("Open", B.definition, { - key: "Other", - delegatesFirst: true, - delegated, - }), - answerProvider("Open", resumedA.definition, { key: "Open" }), + answerProvider("Open", B.definition, { delegatesFirst: true, delegated }), + answerProvider("Open", resumedA.definition), ], }, ], @@ -2917,7 +2858,7 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => expect(wrapped.performed).toEqual([]); }); - it("EL5: a continuation whose retained Elicit identity moved refuses before any effect", function* () { + it("EL5: a continuation that no longer admits Elicit refuses before any effect", function* () { const files = recordedFiles(); const stream = new InMemoryStream(); yield* scoped(function* () { @@ -2932,19 +2873,15 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => expect(admitted).toBeGreaterThanOrEqual(0); const partial = complete.slice(0, admitted + 1); - // The same fragment and the same text, under a table that now states - // another revision behind the same name. + // The same fragment and the same text, under a table that no longer admits + // the question at all. A grant is the exact set of spellings it was made + // under, so a table missing one is a different grant rather than the same + // one restated. const second = recordedFiles(); const moved: ExecutionInstallation = { evaluation: { read: [fileReadEntry()], - write: [ - fileWriteEntry(), - { - ...elicitWriteEntry(), - identity: { origin: "@executablemd/core", key: "Elicit", revision: "99" }, - }, - ], + write: [fileWriteEntry()], }, install: () => useRecordedFiles(second), }; diff --git a/packages/core/tests/evaluate-provider-lifetime.test.ts b/packages/core/tests/evaluate-provider-lifetime.test.ts index 1cf2f5e81..a683014fb 100644 --- a/packages/core/tests/evaluate-provider-lifetime.test.ts +++ b/packages/core/tests/evaluate-provider-lifetime.test.ts @@ -1,7 +1,7 @@ /** * Tier FE15 — what a provider's answer is bound to, at execution scale. * - * `answer-identity.test.ts` holds the unit half: an identity belongs to one + * `answer-identity.test.ts` holds the unit half: a publication belongs to one * object in one execution, and a request that outlived its invocation states * nothing. These rows are the other half — the same guarantees driven through * a real execution, where the provider is installed by a trusted host, the @@ -37,24 +37,16 @@ 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 { retainedSource } from "../src/root-source.ts"; -import { answerProvider, implementation } from "./support/answer-provider.ts"; +import { answerProvider, implementation, offering } from "./support/answer-provider.ts"; import type { Implementation } from "./support/answer-provider.ts"; import type { ComponentInvocation } from "../src/invocation-identity.ts"; const ROOT_PATH = "evaluate.md"; const OPEN = `\\n'} as="answer" />\n\n\n`; -/** What a provider states for the one name these rows admit. */ -const IDENTITY = { key: "Open", revision: "1" } as const; - -/** One provider-backed entry, under the identity this suite's providers state. */ +/** One provider-backed entry, in the one spelling this suite's providers offer. */ function answerEntry(name: string): ComponentAnswerEntry { - return { - kind: "component-answer", - name, - identity: { origin: "test://provider", key: name, revision: "1" }, - forms: ["self-closing"], - }; + return { kind: "component-answer", name, forms: ["self-closing"] }; } /** The entry a host states for a provider-backed name. */ @@ -76,7 +68,7 @@ function refused(attempt: () => unknown): string { const message = error instanceof Error ? error.message : String(error); return message.split(",")[0]?.trim() ?? message; } - throw new Error("expected the claim to be refused"); + throw new Error("expected the publication to be refused"); } function run( @@ -97,7 +89,7 @@ function refusalFrom(request: ComponentAnswerRequest | undefined): string { throw new Error("the provider was never asked"); } try { - request.claim(implementation("Open", "late").definition, { key: "Open", revision: "1" }); + request.answer(implementation("Open", "late").definition); } catch (error) { return error instanceof Error ? error.message : String(error); } @@ -163,7 +155,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur }); reached.resolve(); yield* suspend(); - return request.claim(A.definition, { key: "Open", revision: "1" }); + return request.answer(A.definition); }); }, }, @@ -240,8 +232,8 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur // Two providers under one name. The first is installed outermost, is // genuinely asked, keeps the request it was handed and delegates without - // claiming — which is what losing looks like. The second answers, and the - // capture seals what it claimed. + // publishing — which is what losing looks like. The second answers, and + // the capture seals what it published. // // `` runs from the document, *after* the capture and while this // execution is still live — the window a post-run row cannot reach. So what @@ -263,10 +255,10 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur // import, and this handler returned before that. The execution is // still very much alive — so what refuses is the invocation, not // the teardown. - attempted.push(refused(() => held.claim(A.definition, IDENTITY))); + attempted.push(refused(() => held.answer(A.definition))); // The same for the winner's own implementation: there is no live // request to retag it through either. - attempted.push(refused(() => held.claim(B.definition, IDENTITY))); + attempted.push(refused(() => held.answer(B.definition))); return "meddled"; }, }, @@ -304,7 +296,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur expect(A.invoked).toEqual([]); }); - it("FE15: a losing handler cannot claim once it has returned, mid-resolution", function* () { + it("FE15: a losing handler cannot publish once it has returned, mid-resolution", function* () { const A = implementation("Open", "A ran"); const B = implementation("Open", "B ran"); const inner: ComponentAnswerRequest[] = []; @@ -314,7 +306,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur let innerSettled = false; // The frozen mid-resolution race. The inner provider is asked, keeps its - // request and returns without claiming; the outer provider is *still + // request and returns without publishing; the outer provider is *still // running* the same resolution, and invokes the inner one's request from // inside its own handler. The window is open, the name is right, the // execution is live — and the invocation that would have been speaking is @@ -324,7 +316,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur evaluation: admits(), componentAnswers: [ { - // The origin the profile admits: this is the provider whose claim + // The origin the profile admits: this is the provider whose answer // has to be the one the capture reconciles. origin: "test://provider", *install(registrar: ComponentAnswerRegistrar): Operation { @@ -342,10 +334,10 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur if (held === undefined) { throw new Error("the inner provider was never asked"); } - attempted.push(refused(() => held.claim(A.definition, IDENTITY))); + attempted.push(refused(() => held.answer(A.definition))); // And the outer handler's own request is still live, so it may - // claim its replacement before returning. - return request.claim(B.definition, { key: "Open", revision: "1" }); + // publish its replacement before returning. + return request.answer(B.definition); }); }, }, @@ -357,7 +349,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur return yield* next(); } innerSettled = true; - // Answers, keeps its request, and claims nothing — so it loses + // Answers, keeps its request, and publishes nothing — so it loses // the decision the outer handler goes on to make. inner.push(request); return A.definition; @@ -370,8 +362,8 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur expect(delegated).toHaveLength(1); expect(attempted).toEqual(["this resolution has settled"]); - // The outer replacement is what ran, which is the positive half: claiming - // after delegating has to keep working. + // The outer replacement is what ran, which is the positive half: + // publishing after delegating has to keep working. expect(String(output)).toContain("B ran"); expect(B.invoked).toEqual(["B ran"]); expect(A.invoked).toEqual([]); @@ -398,7 +390,6 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur componentAnswers: [ answerProvider("Open", A.definition, { retain: stale }), answerProvider("Other", B.definition, { - key: "Other", whileResolving: () => { const held = stale[0]; if (held === undefined) { @@ -408,7 +399,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur // even address the name being decided — and its own invocation // is over. expect(held.name).toBe("Open"); - attempted.push(refused(() => held.claim(late.definition, IDENTITY))); + attempted.push(refused(() => held.answer(late.definition))); carried.push(A.definition); }, }), @@ -420,7 +411,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur expect(attempted).toEqual(["this resolution has settled"]); expect(carried).toEqual([A.definition]); // Both names ran what their own resolution sealed — which is also the - // positive control that the second provider's own claim, made in the same + // positive control that the second provider's own publication, made in the same // window as the refusal, was admitted. expect(String(output)).toContain("A ran"); expect(String(output)).toContain("B ran"); @@ -463,10 +454,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur } answered.push(request.name); requests.push(request); - return request.claim(held.definition, { - key: request.name, - revision: "1", - }); + return request.answer(held.definition); }); }, }, @@ -501,6 +489,8 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur kind: "function" as const, name: "Open", props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing" as const], + generated: offering("Open", "read"), *fn(): Operation { first.push("A ran"); entered.resolve(); @@ -542,11 +532,11 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur // 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([ - { name: "Open", identity: { origin: "test://provider", key: "Open", revision: "1" } }, - ]); + expect(prepared.answered).toEqual(["Open"]); - const answers = new Map([["Open", { definition: A.definition }]]); + const answers = new Map([ + ["Open", { definition: A.definition, origin: "test://provider" }], + ]); const profile = yield* prepared.seal(answers); const sealed = profile.read[0]?.definition.fn; if (typeof sealed !== "function") { @@ -581,7 +571,7 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur expect(String(first)).toContain("A ran"); // The same document and the same profile, with no provider installed. The - // first run's answer is not somewhere the second can find it: a claim leaves + // 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() }])); @@ -599,6 +589,8 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur kind: "function" as const, name: "Open", props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing" as const], + generated: offering("Open", "read"), *fn(): Operation { yield* ensure(function* () { cleanup.push("body cleanup"); diff --git a/packages/core/tests/evaluation-profile.test.ts b/packages/core/tests/evaluation-profile.test.ts index 291f35b37..73c35e3f3 100644 --- a/packages/core/tests/evaluation-profile.test.ts +++ b/packages/core/tests/evaluation-profile.test.ts @@ -28,7 +28,7 @@ import { syntaxReadEntry, } from "../src/evaluation-profile.ts"; import { CORE_ORIGIN } from "../src/components/registry.ts"; -import { CORE_REVISION } from "../src/generated-xmd.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"; @@ -38,9 +38,11 @@ import type { EvaluationProfile, ComponentAnswerEntry, FragmentEvaluationInput, + FragmentForm, ResolvedAnswer, ResolvedAnswers, } from "../src/evaluation-profile.ts"; +import type { GeneratedForms } from "../src/components/generated-metadata.ts"; import type { Json } from "../src/types.ts"; describe("protected route projection", () => { @@ -52,6 +54,13 @@ describe("protected route projection", () => { name, origin: "test://protected", props: { type: "object" }, + forms: ["self-closing" as const], + generated: { + "self-closing": { + identity: `https://executable.md/components/${name.toLowerCase()}/read`, + effect: "read" as const, + }, + }, build: (claim) => function* (_props, invocation): Operation { return `${name}:${yield* claim(invocation)}`; @@ -92,17 +101,10 @@ describe("protected route projection", () => { throw new Error("missing protected definition"); } const prepared = yield* prepareEvaluationProfile({ - read: [ - { - kind: "component-answer", - name: "Admitted", - identity: { origin: "test://provider", key: "Admitted", revision: "1" }, - forms: ["self-closing"], - }, - ], + read: [{ kind: "component-answer", name: "Admitted", forms: ["self-closing"] }], }); const captured = yield* prepared.seal( - new Map([["Admitted", { definition: original }]]), + new Map([["Admitted", { definition: original, origin: "test://protected" }]]), owner.componentRouting.project, ); const sealed = captured.read[0]?.definition.fn; @@ -197,7 +199,6 @@ function entry(overrides: Partial = {}): CapabilityEntry { return { kind: "capability", name, - identity: { origin: "test://host", key: "File:read", revision: "1" }, forms: ["self-closing"], props: { type: "object", properties: {}, additionalProperties: false }, // The capability follows the name unless a row states otherwise: what these @@ -246,7 +247,7 @@ describe("Tier EP — a captured profile stops reading the host's objects", () = const read: CapabilityEntry[] = [entry()]; const captured = yield* capture({ read }); - read.push(entry({ name: "Added", identity: { origin: "t", key: "Added", revision: "1" } })); + read.push(entry({ name: "Added" })); read.length = 0; expect(captured.read).toHaveLength(1); @@ -274,13 +275,16 @@ describe("Tier EP — a captured profile stops reading the host's objects", () = expect(names).toEqual(["path"]); }); - it("EP3: an identity the host edits after capture does not change what is compared", function* () { - const identity = { origin: "test://host", key: "Probe", revision: "1" }; - const captured = yield* capture({ read: [entry({ identity })] }); + 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 })] }); - identity.revision = "2"; + // 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"); - expect(captured.read[0]?.identity.revision).toBe("1"); + expect(captured.read[0]?.forms).toEqual(["self-closing"]); }); it("EP4: request headers and lists the host mutates do not widen the ceiling", function* () { @@ -372,17 +376,13 @@ describe("Tier EP — a captured profile stops reading the host's objects", () = const captured = yield* capture({ read: [ entry({ name: "First", requests: [{ url: "https://api.example.test/first" }] }), - entry({ - name: "Second", - identity: { origin: "test://host", key: "Second", revision: "1" }, - requests: [{ url: "https://api.example.test/second" }], - }), + 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 identity it bounds. + // 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", ]); @@ -429,15 +429,21 @@ describe("Tier EP — a captured profile stops reading the host's objects", () = }); describe("Tier EP — a profile a host cannot state", () => { - it("EP12: an entry with no complete identity refuses", function* () { - const attempts: Partial[] = [ - { identity: { origin: "", key: "Probe", revision: "1" } }, - { identity: { origin: "test://host", key: "", revision: "1" } }, - { identity: { origin: "test://host", key: "Probe", revision: "" } }, - ]; - for (const attempt of attempts) { - expect(yield* refusal({ read: [entry(attempt)] })).toContain("complete identity"); - } + 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"] })], + }); + expect(unoffered).toContain("offers no generated use of it"); }); it("EP13: an entry admitted for no form refuses", function* () { @@ -479,32 +485,45 @@ describe("Tier EP — a profile a host cannot state", () => { const captured = yield* capture({ read: [fileReadEntry(), globReadEntry()] }); const [file, glob] = captured.read; - expect(file?.identity).toEqual({ origin: CORE_ORIGIN, key: "File:read", revision: "2" }); + // 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); // 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?.identity).toEqual({ origin: CORE_ORIGIN, key: "Glob", revision: "2" }); + expect(glob?.definition.generated).toEqual({ + "self-closing": { identity: "https://executable.md/components/glob/read", effect: "read" }, + }); expect(glob?.forms).toEqual(["self-closing"]); expect(glob?.props).toEqual(globProps); expect(glob?.definition.returns).toEqual({ type: "array", items: { type: "string" } }); - // A search is not an older grant renamed: nothing was ever retained under - // a version-1 identity for it, so it answers for none. - expect(glob?.legacy).toBe(undefined); + // 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 at core's own identity", function* () { + 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.identity).toEqual({ origin: CORE_ORIGIN, key: "Syntax", revision: "2" }); expect(stated.forms).toEqual(["self-closing"]); - // The arm carries no implementation at all: what is behind a protected name - // is canonical resolution's to answer, not a host's to hand over. + // 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. + expect(SYNTAX_PROTECTED.generated).toEqual({ + "self-closing": { identity: "https://executable.md/components/syntax/read", effect: "read" }, + }); }); it("EP23: one name admitted for two different components refuses", function* () { @@ -525,12 +544,41 @@ describe("Tier EP — a profile a host cannot state", () => { }); it("EP16: forms are canonically ordered, so two statements of one pair match", function* () { - const one = yield* capture({ - read: [entry({ forms: ["paired", "self-closing"] })], - }); - const other = yield* capture({ - read: [entry({ forms: ["self-closing", "paired"] })], - }); + // 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" }, + }; + const answers: ResolvedAnswers = new Map([ + [ + "Pair", + { + origin: "test://provider", + definition: { + kind: "function" as const, + name: "Pair", + props: { type: "object", properties: {}, additionalProperties: false }, + generated: both, + // deno-lint-ignore require-yield + *fn(): Operation { + return "paired"; + }, + }, + }, + ], + ]); + 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"]); @@ -543,27 +591,34 @@ describe("Tier EP — a profile a host cannot state", () => { * 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, and a second identity for the second spelling would make which - * implementation a fragment reached depend on which table admitted it. + * component, each reading its own identity and effect off the one definition + * the chain answered with. */ -describe("Tier EP — a provider-backed name states one identity", () => { +describe("Tier EP — a provider-backed name resolves once to one definition", () => { const OPEN: ComponentAnswerEntry = { kind: "component-answer", name: "Open", - identity: { origin: "test://provider", key: "Open", revision: "1" }, forms: ["self-closing"], }; - /** One implementation, as canonical execution hands sealing its answer. */ - function answered(): ResolvedAnswers { + /** + * 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. + */ + function answered(generated: GeneratedForms = BOTH_FORMS): ResolvedAnswers { return new Map([ [ "Open", { + origin: "test://provider", definition: { kind: "function", name: "Open", props: { type: "object", properties: {}, additionalProperties: false }, + generated, // deno-lint-ignore require-yield *fn(): Operation { return "opened"; @@ -574,11 +629,30 @@ describe("Tier EP — a provider-backed name states one identity", () => { ]); } + /** 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 { @@ -592,8 +666,7 @@ describe("Tier EP — a provider-backed name states one identity", () => { 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 same identity for both, because there is only one thing behind the - // name. + // the name, and the definition says what each spelling runs as. const prepared = yield* prepare({ read: [OPEN], write: [{ ...OPEN, forms: ["paired"] }], @@ -601,15 +674,18 @@ describe("Tier EP — a provider-backed name states one identity", () => { // 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([ - { name: "Open", identity: { origin: "test://provider", key: "Open", revision: "1" } }, - ]); + 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 + // spelling and the writing one are different grants, and the implementation + // 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 // two lifetimes for one implementation, and which of them a fragment // reached would depend on which table admitted it. @@ -617,33 +693,25 @@ describe("Tier EP — a provider-backed name states one identity", () => { expect(observing?.props).toBe(mutating?.props); }); - it("EP19: a second identity for one name refuses, naming both", function* () { - const cases: readonly (readonly [string, Partial])[] = [ - [ - "within one table", - { read: [OPEN, { ...OPEN, identity: { ...OPEN.identity, revision: "2" } }] }, - ], - [ - "across the two tables", - { - read: [OPEN], - write: [{ ...OPEN, forms: ["paired"], identity: { ...OPEN.identity, key: "Other" } }], - }, - ], - [ - "under another origin", - { read: [OPEN, { ...OPEN, identity: { ...OPEN.identity, origin: "test://other" } }] }, - ], - ]; - - for (const [where, input] of cases) { - const failed = yield* refused(input); - // Refused rather than resolved by position: a last-stated identity - // winning would be a ceiling decided by the order a host assembled its - // tables in. - expect([where, failed.includes("One name states one identity")]).toEqual([where, true]); - expect([where, failed.includes("test://provider#Open@1")]).toEqual([where, true]); - } + it("EP19: an answer that does not offer the admitted spelling refuses at sealing", 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"] }] }); + 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"); + + 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", + ); }); it("EP20: one name held as both a capability and an answer refuses", function* () { @@ -659,7 +727,7 @@ describe("Tier EP — a provider-backed name states one identity", () => { }); describe("Tier EP — the entry that admits canonical ``", () => { - it("EL1: the factory pins core's own body under core's identity, paired only", function* () { + 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 @@ -671,11 +739,7 @@ describe("Tier EP — the entry that admits canonical ``", () => { expect(entry.kind).toBe("capability"); expect(entry.capability).toBe("elicit:ask"); expect(entry.name).toBe("Elicit"); - expect(entry.identity).toEqual({ - origin: CORE_ORIGIN, - key: "Elicit", - revision: CORE_REVISION, - }); + 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"]); @@ -689,9 +753,6 @@ describe("Tier EP — the entry that admits 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); - // A new grant: no version-1 record ever admitted a generated question, so - // there is no older string this says it authorizes no more than. - expect(entry.legacy).toBe(undefined); // Two factories state the same entry and share nothing: a host that edited // what it was given would be editing its own copy. diff --git a/packages/core/tests/generated-composition.test.ts b/packages/core/tests/generated-composition.test.ts index e180df3cc..b28613657 100644 --- a/packages/core/tests/generated-composition.test.ts +++ b/packages/core/tests/generated-composition.test.ts @@ -353,6 +353,10 @@ function unwinding(log: string[]): FunctionComponentDefinition { kind: "function", name: "Held", props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: { + "self-closing": { identity: "https://executable.md/components/held/read", effect: "read" }, + }, *fn() { // Registered before anything the invocation could fail at, so the cleanup // is established rather than merely queued behind a success. @@ -369,14 +373,7 @@ function unwinding(log: string[]): FunctionComponentDefinition { function holding(definition: FunctionComponentDefinition): ExecutionInstallation { return { evaluation: { - read: [ - { - kind: "component-answer", - name: "Held", - identity: { origin: "test://provider", key: "Held", revision: "1" }, - forms: ["self-closing"], - }, - ], + read: [{ kind: "component-answer", name: "Held", forms: ["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 new file mode 100644 index 000000000..4e789cc2c --- /dev/null +++ b/packages/core/tests/generated-owner-metadata.test.ts @@ -0,0 +1,389 @@ +/** + * Tier GOM — what a declaration says about the generated use of its own forms + * (spec §§5.3, 5.6, 6.13). + * + * A generated fragment names a component, and two facts decide what naming it + * grants: which URL a continuation is compared against, and whether the + * spelling observes or mutates. Both belong to the declaration that owns the + * implementation, so this tier is about the declaration — the shapes it may + * state, the shapes it may not, and what survives construction. + * + * Three halves, failing in different places: + * + * **Validation.** A declaration crosses a package boundary, so the metadata is + * read the way durable input is read: exactly two members, an identity in the + * accepted HTTPS namespace, one of the two effects, and a spelling the + * declaration itself accepts. A refused registration batch installs nothing. + * + * **The shipped owners.** ``, ``, ``, ``, + * ``, `` and `` state exactly the table the + * specification does, with ``'s two spellings differing on one + * definition. `` stays trusted composition and `` offers + * nothing, because carrying metadata grants nothing and evaluation inside + * evaluation is not something a table may name. + * + * **Retention.** The shared fixed definitions are frozen through their nested + * metadata, so a change left behind by one execution is not what the next one + * takes its first copy of. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Component } from "../src/component-api.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import type { ComponentRegistration } from "../src/components/registration.ts"; +import { + captureGenerated, + definitionGeneratedRefusal, + generatedRefusal, + isGeneratedIdentity, + offeredForm, +} from "../src/components/generated-metadata.ts"; +import type { GeneratedForms } from "../src/components/generated-metadata.ts"; +import { fixedFilesystemDefinition } from "../src/components/fixed-filesystem.ts"; +import { coreComponentDefinition } from "../src/components/registry.ts"; +import { SYNTAX_PROTECTED } from "../src/components/Syntax.ts"; +import { EVALUATE_PROTECTED } from "../src/components/Evaluate.ts"; +import { pinnedJson } from "../src/generated-xmd.ts"; +import type { ComponentRegistry, FunctionComponentDefinition, Json } from "../src/types.ts"; + +/** The registry visible in the current scope. */ +function currentRegistry(): Operation { + return Component.operations.registry; +} + +/** One registration, with whatever one row states about its generated forms. */ +function registration(overrides: Partial = {}): ComponentRegistration { + return { + name: "Probe", + origin: "test://owner", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return "probed"; + }, + ...overrides, + }; +} + +/** What registering this batch refused with, or nothing when it installed. */ +function* refusal(registrations: readonly ComponentRegistration[]): Operation { + try { + yield* registerComponents([...registrations]); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + return undefined; +} + +/** The metadata on the definition this name resolves to in the current scope. */ +function* registered(name: string): Operation { + const entry = (yield* currentRegistry()).get(name)?.default?.definition; + return entry === undefined || entry.kind !== "function" ? undefined : entry.generated; +} + +const READ_PROBE: GeneratedForms = { + "self-closing": { identity: "https://executable.md/components/probe/read", effect: "read" }, +}; + +describe("Tier GOM — a declaration states what its spellings are offered as", () => { + it("GOM1: an accepted map survives registration, copied and frozen", function* () { + expect(yield* refusal([registration({ generated: READ_PROBE })])).toBe(undefined); + + const held = yield* registered("Probe"); + expect(held).toEqual(READ_PROBE); + // Copied and frozen through, so the declarer's own object is not what the + // engine reads and a nested record cannot be edited afterwards. + expect(held).not.toBe(READ_PROBE); + expect(Object.isFrozen(held)).toBe(true); + expect(Object.isFrozen(offeredForm(held, "self-closing"))).toBe(true); + }); + + it("GOM1: a component offering nothing registers and keeps its ordinary use", function* () { + expect(yield* refusal([registration()])).toBe(undefined); + + // Omission is the ordinary case: metadata is what makes a spelling + // offerable to a generated fragment, and offering none takes nothing away + // from the authored element. + expect(yield* registered("Probe")).toBe(undefined); + const entry = (yield* currentRegistry()).get("Probe"); + expect(entry?.default?.origin).toBe("test://owner"); + }); + + it("GOM1: an empty map offers no spelling, and still registers", function* () { + expect(yield* refusal([registration({ generated: {} })])).toBe(undefined); + + const held = yield* registered("Probe"); + expect(held).toEqual({}); + expect(offeredForm(held, "self-closing")).toBe(undefined); + expect(offeredForm(held, "paired")).toBe(undefined); + }); + + it("GOM1: a malformed map refuses, and a refused batch installs nothing", function* () { + // Each of these is a declaration this engine cannot read: an identity that + // is not an absolute URL in the accepted namespace, a look-alike origin, an + // effect it does not have, a key that is not an authored form, a value that + // is not an identity and an effect, and a spelling the declaration itself + // does not accept. + const attempts: readonly (readonly [string, unknown, readonly string[] | undefined])[] = [ + [ + "a relative identity", + { "self-closing": { identity: "/probe", effect: "read" } }, + undefined, + ], + [ + "a look-alike origin", + { + "self-closing": { + identity: "https://executable.md.example.com/components/probe/read", + effect: "read", + }, + }, + undefined, + ], + [ + "an identity that is not a URL at all", + { "self-closing": { identity: "probe", effect: "read" } }, + undefined, + ], + [ + "an effect this engine does not have", + { + "self-closing": { + identity: "https://executable.md/components/probe/read", + effect: "ask", + }, + }, + undefined, + ], + [ + "a key that is not an authored form", + { either: { identity: "https://executable.md/components/probe/read", effect: "read" } }, + undefined, + ], + ["a value that is not a record", { "self-closing": "read" }, undefined], + [ + "a value carrying a member this engine does not know", + { + "self-closing": { + identity: "https://executable.md/components/probe/read", + effect: "read", + widened: true, + }, + }, + undefined, + ], + ["a map that is not a map", ["read"], undefined], + [ + "a spelling the declaration does not accept", + { paired: { identity: "https://executable.md/components/probe/write", effect: "write" } }, + ["self-closing"], + ], + ]; + + for (const [what, generated, forms] of attempts) { + yield* scoped(function* () { + const declared = Object.assign( + registration(forms === undefined ? {} : { forms: ["self-closing"] }), + { generated }, + ); + // Registered beside a component that is perfectly fine, so the row says + // the batch installed nothing rather than only that one entry refused. + const failed = yield* refusal([ + registration({ name: "Fine", generated: READ_PROBE }), + declared, + ]); + expect([what, failed !== undefined]).toEqual([what, true]); + expect([what, yield* registered("Probe")]).toEqual([what, undefined]); + expect([what, yield* registered("Fine")]).toEqual([what, undefined]); + }); + } + }); + + // deno-lint-ignore require-yield + it("GOM1: an identity is admitted by its origin rather than by its text", function* () { + expect(isGeneratedIdentity("https://executable.md/components/file/read")).toBe(true); + // Each of these starts with the accepted text and names somebody else, so a + // check written as a prefix comparison would admit all three. + expect(isGeneratedIdentity("https://executable.md.example.com/components/file/read")).toBe( + false, + ); + expect( + isGeneratedIdentity("https://executable.md@elsewhere.example/components/file/read"), + ).toBe(false); + expect(isGeneratedIdentity("http://executable.md/components/file/read")).toBe(false); + expect(isGeneratedIdentity("components/file/read")).toBe(false); + }); + + // deno-lint-ignore require-yield + it("GOM1: a definition that never passed registration is read the same way", function* () { + // A provider's answer is a definition no registration validated, so its + // metadata is read off the object defensively — including the forms it + // states, because a definition offering a spelling it does not accept + // offers nothing that can run. + const offered: FunctionComponentDefinition = { + kind: "function", + name: "Probe", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: READ_PROBE, + // deno-lint-ignore require-yield + *fn(): Operation { + return "probed"; + }, + }; + expect(definitionGeneratedRefusal(offered)).toBe(undefined); + expect(definitionGeneratedRefusal({ ...offered, generated: {} })).toBe(undefined); + expect(definitionGeneratedRefusal({ kind: "function", name: "Probe" })).toBe(undefined); + expect( + definitionGeneratedRefusal( + Object.assign( + { ...offered }, + { + generated: { paired: { identity: "https://executable.md/x", effect: "write" } }, + }, + ), + ), + ).toContain("does not accept"); + expect( + definitionGeneratedRefusal( + Object.assign({ ...offered }, { generated: { "self-closing": 1 } }), + ), + ).toContain("not an identity and an effect"); + }); + + // deno-lint-ignore require-yield + it("GOM1: the refusal is a sentence a caller words for itself", function* () { + // A sentence rather than a thrown error, because a registration, a factory + // declaration and a provider's published answer each refuse in their own + // error family and with their own diagnostic context. + expect(generatedRefusal(undefined, undefined)).toBe(undefined); + expect(generatedRefusal(READ_PROBE, undefined)).toBe(undefined); + expect(generatedRefusal(READ_PROBE, ["paired"])).toContain("does not accept"); + expect(captureGenerated(READ_PROBE)).toEqual(READ_PROBE); + }); +}); + +describe("Tier GOM — the owners of the shipped generated forms", () => { + /** Exactly what the specification's owner table states, read off each owner. */ + const OWNERS: readonly (readonly [string, GeneratedForms])[] = [ + [ + "File", + { + "self-closing": { identity: "https://executable.md/components/file/read", effect: "read" }, + paired: { identity: "https://executable.md/components/file/write", effect: "write" }, + }, + ], + [ + "File.Delete", + { + "self-closing": { + identity: "https://executable.md/components/file/delete", + effect: "write", + }, + }, + ], + [ + "Dir", + { paired: { identity: "https://executable.md/components/dir/ensure", effect: "write" } }, + ], + ]; + + for (const [name, generated] of OWNERS) { + // deno-lint-ignore require-yield + it(`GOM2: core's fixed <${name}> offers exactly its own spellings`, function* () { + const definition = fixedFilesystemDefinition(name); + expect(definition?.generated).toEqual(generated); + }); + } + + // deno-lint-ignore require-yield + it("GOM2: File's two spellings differ on one definition", function* () { + const definition = fixedFilesystemDefinition("File"); + const reading = offeredForm(definition?.generated, "self-closing"); + const writing = offeredForm(definition?.generated, "paired"); + + // One component, one definition, two grants: the identity a continuation is + // compared against and the effect a table admits it in both come from the + // spelling rather than from the name. + expect(reading?.effect).toBe("read"); + expect(writing?.effect).toBe("write"); + expect(reading?.identity).not.toBe(writing?.identity); + }); + + // deno-lint-ignore require-yield + it("GOM2: the registry's own offered components state their one spelling each", function* () { + expect(coreComponentDefinition("Glob")?.generated).toEqual({ + "self-closing": { identity: "https://executable.md/components/glob/read", effect: "read" }, + }); + expect(coreComponentDefinition("Fetch")?.generated).toEqual({ + "self-closing": { identity: "https://executable.md/components/fetch/read", effect: "read" }, + }); + expect(coreComponentDefinition("Elicit")?.generated).toEqual({ + paired: { identity: "https://executable.md/components/elicit/ask", effect: "write" }, + }); + }); + + // deno-lint-ignore require-yield + it("GOM2: canonical Syntax offers a read, and Evaluate offers nothing", function* () { + expect(SYNTAX_PROTECTED.generated).toEqual({ + "self-closing": { identity: "https://executable.md/components/syntax/read", effect: "read" }, + }); + // Evaluation inside evaluation is not a grant a table may name, so the + // declaration offers no spelling at all — and carrying metadata would not + // have admitted it either. + expect(EVALUATE_PROTECTED.generated).toBe(undefined); + }); + + // deno-lint-ignore require-yield + it("GOM2: Json stays trusted composition, with no read or write form", function* () { + // Rendering a value it already holds performs nothing, so there is no + // effect for an owner to offer. The composition table states core's own + // identity for it, and that is the one admitted identity not read off an + // owner's metadata. + expect(coreComponentDefinition("Json")?.generated).toBe(undefined); + expect(pinnedJson().identity).toBe("https://executable.md/components/json"); + }); + + // deno-lint-ignore require-yield + it("GOM2: every other core default offers nothing", function* () { + for (const name of ["TempDir", "Fail", "CodeBlock", "Parse", "SafeParse", "Test"]) { + expect([name, coreComponentDefinition(name)?.generated]).toEqual([name, undefined]); + } + }); +}); + +describe("Tier GOM — a shared definition's metadata cannot be edited", () => { + // deno-lint-ignore require-yield + it("GOM3: a fixed definition's nested metadata is frozen through", function* () { + const definition = fixedFilesystemDefinition("File"); + const generated = definition?.generated; + const reading = offeredForm(generated, "self-closing"); + if (definition === undefined || generated === undefined || reading === undefined) { + throw new Error("core supplies no File metadata"); + } + + // This object is shared by every execution in the process, so a change left + // behind by one would be what the next one takes its own first copy of — + // which the per-execution comparison cannot catch. Freezing is what the + // sharing requires. + expect(Object.isFrozen(definition)).toBe(true); + expect(Object.isFrozen(generated)).toBe(true); + expect(Object.isFrozen(reading)).toBe(true); + expect(() => { + Object.assign(reading, { effect: "write" }); + }).toThrow(); + expect(() => { + Object.assign(generated, { paired: reading }); + }).toThrow(); + + // And a fresh read sees the original contract rather than an attempted one. + expect(offeredForm(fixedFilesystemDefinition("File")?.generated, "self-closing")).toEqual({ + identity: "https://executable.md/components/file/read", + effect: "read", + }); + }); +}); diff --git a/packages/core/tests/generated-xmd.test.ts b/packages/core/tests/generated-xmd.test.ts index d702adf01..ffb6702cb 100644 --- a/packages/core/tests/generated-xmd.test.ts +++ b/packages/core/tests/generated-xmd.test.ts @@ -68,8 +68,8 @@ import type { GeneratedMutation, GeneratedObservation, GeneratedXmdRequest, - RetainedFragmentIdentity, } from "../host.ts"; +import type { GeneratedEffect, GeneratedForms } from "../src/components/generated-metadata.ts"; import type { FunctionComponentDefinition, Json, JsonObject } from "../src/types.ts"; const ROOT_PATH = "workflows/agent.md"; @@ -81,14 +81,18 @@ const ROOT_PATH = "workflows/agent.md"; * asked for and before whatever the selection added. Written here once, because * every record below has to account for it. */ -const COMPOSITION = [{ name: "Json", identity: pinnedJson().identity, forms: ["self-closing"] }]; +const COMPOSITION = [ + { + name: "Json", + form: "self-closing", + identity: pinnedJson().identity, + kind: "capability", + effect: "composition", + }, +]; -/** The same entry as journal data, for a record a test forges by hand. */ -const COMPOSITION_RECORD: JsonObject = { - name: "Json", - identity: { ...pinnedJson().identity }, - forms: ["self-closing"], -}; +/** The same row as journal data, for a record a test forges by hand. */ +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"; @@ -97,21 +101,30 @@ const ROOTS = ["workspace://primary", "workspace://secondary"]; const ADVANCED = "workspace://advanced"; /** - * One host-stated identity, in the shape a run retains. + * What one host component's declaration offers a spelling as. * - * `component-answer`, because everything a test admits here is an - * implementation the host holds rather than an operation core supplies the body - * for — which is the distinction the kind exists to keep. + * The identity and the effect are on the definition, because that is where the + * declaration that owns the implementation states them: a host admits a name + * and a spelling, and what admitting it grants is the component's own. */ -function hostIdentity(origin: string, key: string, revision = "1"): RetainedFragmentIdentity { - return { kind: "component-answer", origin, key, revision }; +function offers( + key: string, + effect: GeneratedEffect, + form: "self-closing" | "paired" = "self-closing", +): GeneratedForms { + return { [form]: { identity: `https://executable.md/components/${key}`, effect } }; } +/** The identity `` is admitted under, wherever a row names it. */ +const PROBE_IDENTITY = "https://executable.md/components/probe/read"; + /** The one host observation component the tests admit beside ``. */ const PROBE: FunctionComponentDefinition = { 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 { return "probed"; @@ -119,7 +132,45 @@ const PROBE: FunctionComponentDefinition = { }; function probe(): GeneratedObservation { - return pinnedComponent("Probe", hostIdentity("test://probe", "Probe"), PROBE); + return pinnedComponent("Probe", PROBE, "component-answer"); +} + +/** + * One host component offered to observe in both spellings, and one offered to + * mutate in both. + * + * `` offers one reading spelling, which is the ordinary shape; these + * two are for the rows about what a table may say when a component offers both + * — the content of a paired read, and two entries fighting over one spelling. + */ +const READS: FunctionComponentDefinition = { + kind: "function", + name: "Reads", + props: { type: "object", properties: {}, additionalProperties: false }, + generated: { + "self-closing": { identity: "https://executable.md/components/reads/read", effect: "read" }, + paired: { identity: "https://executable.md/components/reads/also", effect: "read" }, + }, + *fn(): Operation { + return yield* content(); + }, +}; + +const WRITES: FunctionComponentDefinition = { + kind: "function", + name: "Writes", + props: { type: "object", properties: {}, additionalProperties: false }, + generated: { + "self-closing": { identity: "https://executable.md/components/writes/one", effect: "write" }, + paired: { identity: "https://executable.md/components/writes/both", effect: "write" }, + }, + *fn(): Operation { + return yield* content(); + }, +}; + +function reads(): GeneratedObservation { + return pinnedComponent("Reads", READS, "component-answer"); } describe("generated component resolution stays internal", () => { @@ -363,34 +414,36 @@ 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 version-2 shape: tagged, and carrying the Workspace basis as one - // member rather than two top-level ones, because a host may have none. - // The identity is the closed structural record rather than a spelling: four - // members a reader can compare one at a time, so an admission that moved - // says which part moved. - const PROBE_IDENTITY = { + // 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 + // effect table admitted it — so an admission that moved says which + // spelling of which name moved. + const PROBE_ROW = { + name: "Probe", + form: "self-closing", + identity: PROBE_IDENTITY, kind: "component-answer", - origin: "test://probe", - key: "Probe", - revision: "1", + effect: "read", }; expect(admission.description.input).toMatchObject({ - version: 2, + version: 3, workspace: { roots: ROOTS, selectedRoot: ROOTS[0] }, - allowed: [...COMPOSITION, { name: "Probe", identity: PROBE_IDENTITY }], + allowed: [...COMPOSITION, PROBE_ROW], }); expect(admission.result).toMatchObject({ status: "ok", value: { decision: "admitted", source: "\n", - named: [{ name: "Probe", identity: PROBE_IDENTITY }], + named: [{ name: "Probe", identity: PROBE_IDENTITY, form: "self-closing" }], // 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: 2, + version: 3, workspace: { roots: ROOTS, selectedRoot: ROOTS[0] }, - allowed: [...COMPOSITION, { name: "Probe", identity: PROBE_IDENTITY }], + allowed: [...COMPOSITION, PROBE_ROW], requests: [], }, }, @@ -506,7 +559,8 @@ describe("Tier GX — the complete fragment is read first", () => { const transport = yield* useTransport(() => ({ status: 200 })); const attempt = yield* evaluate( - request("\n\n```bash exec\nprintf ran\n```\n\n\n", [ + request("\n\n```bash exec\nprintf ran\n```\n\n\n", [ + reads(), pinnedFetch([ADMITTED_REQUEST]), probe(), ]), @@ -784,43 +838,23 @@ describe("Tier GX — the request a generated fragment may perform", () => { }); /** - * Two identities that differ only in where a space falls. + * One admitted read that states a ceiling and records what it was asked. * - * A ceiling belongs to the identity that states it, and the run keys its own - * ceiling table by that identity. A key built by joining the four terms with - * a separator is only as unique as the separator is illegal — and nothing - * makes a space illegal in an origin or a component key. These two are the - * smallest pair that collides under a space-joined key and not under the - * terms themselves, so a table that shared one entry's limit with another - * fails here rather than somewhere a host would have to notice. + * 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. */ - const SPACED_ONE: RetainedFragmentIdentity = { - kind: "capability", - origin: "test://host a", - key: "b", - revision: "1", - }; - const SPACED_OTHER: RetainedFragmentIdentity = { - kind: "capability", - origin: "test://host", - key: "a b", - revision: "1", - }; - - /** One admitted read that states a ceiling and records what it was asked. */ - function bounded( - name: string, - identity: RetainedFragmentIdentity, - url: string, - performed: string[], - ): GeneratedObservation { + function bounded(name: string, url: string, performed: string[]): GeneratedObservation { return { name, - identity, + 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" } }, @@ -836,18 +870,18 @@ describe("Tier GX — the request a generated fragment may perform", () => { }; } - it("GX14b: two identities that collide under a joined key keep their own ceilings", function* () { + it("GX14b: two admitted reads keep their own ceilings", function* () { const performed: string[] = []; const attempt = yield* evaluate( request(`\n\n\n`, [ - bounded("One", SPACED_ONE, URL_ONE, performed), - bounded("Other", SPACED_OTHER, URL_TWO, performed), + bounded("One", URL_ONE, performed), + bounded("Other", URL_TWO, performed), ]), ); - // Each element performed the request its own entry stated. A shared key - // would have left one of the two holding the other's limit, and one of - // these two admitted requests would have been refused. + // 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. @@ -862,14 +896,13 @@ describe("Tier GX — the request a generated fragment may perform", () => { const performed: string[] = []; const attempt = yield* evaluate( request(`\n`, [ - bounded("One", SPACED_ONE, URL_ONE, performed), - bounded("Other", SPACED_OTHER, URL_TWO, performed), + 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 however - // the two identities are spelled. + // 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([]); @@ -1124,11 +1157,12 @@ describe("Tier GX — nested generated effects belong to the owning expansion", function durableWrite(executed: string[]): GeneratedMutation { return pinnedMutation( "Write", - hostIdentity("test://durable-write", "Write"), { kind: "function", name: "Write", props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["paired"], + generated: offers("write/ensure", "write", "paired"), *fn(): Operation { const body = yield* content(); yield createDurableOperation( @@ -1357,16 +1391,22 @@ describe("Tier GX — a malformed generated request reports its class, not itsel /** The probe under its admitted identity, counting live invocations. */ function countingProbe(performed: string[]): GeneratedObservation { - return pinnedComponent("Probe", hostIdentity("test://probe", "Probe"), { - kind: "function", - name: "Probe", - props: { type: "object", properties: {}, additionalProperties: false }, - // deno-lint-ignore require-yield - *fn(): Operation { - performed.push("probed"); - return "probed"; + 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) { @@ -1423,75 +1463,16 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde kind: "function", name: "Probe", props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + // A different implementation, offered under a different URL by its own + // declaration — which is what a changed grant behind one name looks like. + generated: offers("other/read", "read"), // deno-lint-ignore require-yield *fn(): Operation { return "the other implementation ran"; }, }; - /** - * The probe under its admitted identity, counting live invocations — so a - * case proves whether a continuation reached the generated component rather - * than inferring it from rendered text alone. - */ - function countedProbe(performed: string[]): GeneratedObservation { - return pinnedComponent("Probe", hostIdentity("test://probe", "Probe"), { - kind: "function", - name: "Probe", - props: { type: "object", properties: {}, additionalProperties: false }, - // deno-lint-ignore require-yield - *fn(): Operation { - performed.push("probed"); - return "probed"; - }, - }); - } - - /** - * The identity this run states for the host observation a version-1 record - * named. - * - * A capability, because that is the only arm that existed when untagged - * records were written: the component-answer arm came with this issue, so no - * version-1 string ever described one. - */ - const V1_IDENTITY: RetainedFragmentIdentity = { - kind: "capability", - origin: "test://probe", - key: "Probe", - revision: "1", - }; - - /** - * The exact string a version-1 journal holds for it. - * - * Arbitrary, and that is the point. Under `bb2c1c49` this string was whatever - * the host passed to `pinnedComponent`, retained verbatim — there was no - * spelling rule at all, so nothing recovers `V1_IDENTITY` from it. The - * current entry states it as an alias, which is the only thing that connects - * the two. - */ - const V1_SPELLING = "urn:host:probe/v1"; - - /** The same counting probe, stating the version-1 identity it succeeds. */ - function versionOneProbe(performed: string[]): GeneratedObservation { - return pinnedComponent( - "Probe", - V1_IDENTITY, - { - kind: "function", - name: "Probe", - props: { type: "object", properties: {}, additionalProperties: false }, - // deno-lint-ignore require-yield - *fn(): Operation { - performed.push("probed"); - return "probed"; - }, - }, - [V1_SPELLING], - ); - } - /** * A history left by a run interrupted during preparation: the admission * committed, and nothing after it did. @@ -1556,398 +1537,214 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde } /** - * The untagged #369 policy, written out rather than derived. + * The policy a current build writes, written out rather than derived. * - * A run suspended before this build wrote exactly this: no version member, - * and the Workspace basis as two mandatory top-level ones. Deriving it by - * rewriting a record this build produced would prove only that the rewrite - * and the reader agree; a literal is what an older journal actually holds. + * A literal, because what these rows are about is the record: deriving it by + * rewriting one this build produced would prove only that the rewrite and the + * reader agree. One row per admitted spelling, each holding the URL its owner + * offers that spelling under. */ - const V1_POLICY: JsonObject = Object.freeze({ + const CURRENT_POLICY: JsonObject = Object.freeze({ + version: 3, allow: ["read"], - roots: [...ROOTS], - selectedRoot: ROOTS[0], - allowed: [{ name: "Probe", identity: V1_SPELLING, forms: ["self-closing", "paired"] }], + workspace: { roots: [...ROOTS], selectedRoot: ROOTS[0] }, + allowed: [ + { ...COMPOSITION_RECORD }, + { + name: "Probe", + form: "self-closing", + identity: PROBE_IDENTITY, + kind: "component-answer", + effect: "read", + }, + ], requests: [], }); - /** One frozen version-1 admission, as an older build committed it. */ - function versionOneAdmission(source: string): DurableEvent { + /** One admission holding that policy, as the event a journal holds. */ + function currentAdmission(source: string, policy: JsonObject = CURRENT_POLICY): DurableEvent { return { type: "yield", coroutineId: "root", - description: { type: "generated_xmd", name: "generated:turn-1", input: V1_POLICY }, + description: { type: "generated_xmd", name: "generated:turn-1", input: policy }, result: { status: "ok", value: { + version: 3, decision: "admitted", source, - named: [{ name: "Probe", identity: V1_SPELLING, form: "self-closing" }], - policy: V1_POLICY, + named: [{ name: "Probe", identity: PROBE_IDENTITY, form: "self-closing" }], + policy, }, }, }; } - /** One frozen version-1 refusal, in the shape that build wrote refusals. */ - function versionOneRefusal(): DurableEvent { - return { - type: "yield", - coroutineId: "root", - description: { type: "generated_xmd", name: "generated:turn-1", input: V1_POLICY }, - result: { status: "ok", value: { decision: "refused", construct: "block" } }, - }; - } - /** - * A history whose admission is that literal version-1 record. + * A history whose admission is one literal record. * * The events around it are a real run's, because what is under test is the * record rather than the journal's framing. */ - function withVersionOne( - events: readonly DurableEvent[], - admission: DurableEvent, - ): DurableEvent[] { + function withAdmission(events: readonly DurableEvent[], admission: DurableEvent): DurableEvent[] { return events.map((event) => event.type === "yield" && event.description.type === "generated_xmd" ? admission : event, ); } /** - * The literal version-1 admission a `bb2c1c49` workflow run committed for the - * standard read table. + * The untagged #369 admission a released build committed. * - * Written out from what that build actually emitted rather than derived: - * `pinnedFileRead()` retained `@executablemd/core#File:read` — one opaque - * value, no revision in it, no separator convention to read back out — beside - * the untagged policy shape with its two top-level Workspace members. + * Written out from what that build actually emitted: one opaque identity + * string per entry, no version member, and the Workspace basis as two + * mandatory top-level members. Nothing recovers a current row from it — there + * was no spelling rule to invert — so this build refuses it as unreadable + * history rather than reading the members it recognizes. */ - const V1_STANDARD_POLICY: JsonObject = Object.freeze({ - allow: ["read"], - roots: [...ROOTS], - selectedRoot: ROOTS[0], - allowed: [{ name: "File", identity: "@executablemd/core#File:read", forms: ["self-closing"] }], - requests: [], - }); - - /** That admission, as the event an older journal holds. */ - function versionOneStandard(source: string): DurableEvent { - return { - type: "yield", - coroutineId: "root", - description: { type: "generated_xmd", name: "generated:turn-1", input: V1_STANDARD_POLICY }, - result: { - status: "ok", - value: { - decision: "admitted", - source, - named: [{ name: "File", identity: "@executablemd/core#File:read", form: "self-closing" }], - policy: V1_STANDARD_POLICY, + const UNTAGGED_RECORD: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { + type: "generated_xmd", + name: "generated:turn-1", + input: { + allow: ["read"], + roots: [...ROOTS], + selectedRoot: ROOTS[0], + allowed: [ + { name: "Probe", identity: "urn:host:probe/v1", forms: ["self-closing", "paired"] }, + ], + requests: [], + }, + }, + result: { + status: "ok", + value: { + decision: "admitted", + source: "\n", + named: [{ name: "Probe", identity: "urn:host:probe/v1", form: "self-closing" }], + policy: { + allow: ["read"], + roots: [...ROOTS], + selectedRoot: ROOTS[0], + allowed: [ + { name: "Probe", identity: "urn:host:probe/v1", forms: ["self-closing", "paired"] }, + ], + requests: [], }, }, - }; - } + }, + }; /** - * One literal version-1 admission, in the untagged shape and holding whatever - * a released build retained for the entry the case is about. + * The tagged admission the build before this one committed. + * + * Version 2 held an identity as a closed structural record — what kind of + * thing was behind the name, the origin that owned it, the component key and + * the revision — and a list of forms per entry. Every term of that shape is + * gone, and reading one as though a URL could be recovered from it would + * compare a fragment's admission equal to an admission it never had. */ - function versionOneRecord( - source: string, - allow: readonly string[], - allowed: readonly JsonObject[], - requests: readonly JsonObject[] = [], - ): DurableEvent { - const policy: JsonObject = { - allow: [...allow], - roots: [...ROOTS], - selectedRoot: ROOTS[0], - allowed: allowed.map((entry) => ({ ...entry })), - requests: requests.map((request) => ({ ...request })), - }; - return { - type: "yield", - coroutineId: "root", - description: { type: "generated_xmd", name: "generated:turn-1", input: policy }, - result: { - status: "ok", - value: { - decision: "admitted", - source, - named: allowed.map((entry) => ({ - name: entry.name, - identity: entry.identity, - form: Array.isArray(entry.forms) ? entry.forms[0] : "self-closing", - })), - policy, + const STRUCTURAL_RECORD: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { + type: "generated_xmd", + name: "generated:turn-1", + input: { + version: 2, + allow: ["read"], + workspace: { roots: [...ROOTS], selectedRoot: ROOTS[0] }, + allowed: [ + { + name: "Probe", + identity: { + kind: "component-answer", + origin: "test://probe", + key: "Probe", + revision: "1", + }, + forms: ["self-closing"], + }, + ], + requests: [], + }, + }, + result: { + status: "ok", + value: { + version: 2, + decision: "admitted", + source: "\n", + named: [ + { + name: "Probe", + identity: { + kind: "component-answer", + origin: "test://probe", + key: "Probe", + revision: "1", + }, + form: "self-closing", + }, + ], + policy: { + version: 2, + allow: ["read"], + workspace: { roots: [...ROOTS], selectedRoot: ROOTS[0] }, + allowed: [ + { + name: "Probe", + identity: { + kind: "component-answer", + origin: "test://probe", + key: "Probe", + revision: "1", + }, + forms: ["self-closing"], + }, + ], + requests: [], }, }, - }; - } + }, + }; /** - * The workflow host's own ``, at the identity and alias it states. - * - * Built here rather than imported, because what is under test is the seam a - * host reaches: `pinnedMutation` takes the structural identity and the exact - * version-1 string, and this is the string a released workflow run retained. + * The probe under its admitted identity, counting live invocations — so a + * case proves whether a continuation reached the generated component rather + * than inferring it from rendered text alone. */ - function dirEntry(executed: string[]): GeneratedMutation { - return pinnedMutation( - "Dir", - { - kind: "capability", - origin: "@executablemd/workflow/composition", - key: "Dir", - revision: "3", - }, + function countedProbe(performed: string[]): GeneratedObservation { + return pinnedComponent( + "Probe", { kind: "function", - name: "Dir", - props: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], - additionalProperties: false, - }, + name: "Probe", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: offers("probe/read", "read"), // deno-lint-ignore require-yield - *fn(props: Record): Operation { - executed.push(`dir:${String(props.path)}`); - return ""; + *fn(): Operation { + performed.push("probed"); + return "probed"; }, }, - "paired", - ["@executablemd/workflow/composition/dir-v2#Dir"], + "component-answer", ); } - /** - * Every version-1 identity a released build actually retained, and the entry - * that states it as the one it succeeds. - * - * One row per alias rather than one row for the mechanism, because each of - * these is a separate literal assertion: the alias is written out at the - * entry, and a journal holding that exact string has to reconcile to it. - * `` is here too — it is the workflow host's string, not core's, and the - * host-supplied path has to work for the same reason core's does. - */ - const RELEASED_ALIASES: readonly (readonly [ - string, - string, - () => GeneratedXmdRequest, - readonly string[], - readonly JsonObject[], - readonly JsonObject[], - ])[] = [ - [ - "core's Fetch", - `\n`, - () => request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]), - ["read"], - [ - { - name: "Fetch", - identity: "@executablemd/core#Fetch", - forms: ["self-closing", "paired"], - }, - ], - [{ url: URL_ONE, method: "GET", headers: {} }], - ], - [ - "core's read-only File", - `\n`, - () => request(`\n`, [pinnedFileRead()]), - ["read"], - [{ name: "File", identity: "@executablemd/core#File:read", forms: ["self-closing"] }], - [], - ], - [ - "core's paired File", - `from the fragment\n`, - () => - selecting(`from the fragment\n`, [], { - allow: ["write"], - mutations: [pinnedFileWrite()], - }), - ["write"], - [{ name: "File", identity: "@executablemd/core#File:write", forms: ["paired"] }], - [], - ], - [ - "core's File.Delete", - `\n`, - () => - selecting(`\n`, [], { - allow: ["write"], - mutations: [pinnedFileDelete()], - }), - ["write"], - [ - { - name: "File.Delete", - identity: "@executablemd/core#File.Delete", - forms: ["self-closing"], - }, - ], - [], - ], - ]; - - for (const [what, source, candidate, allow, allowed, requests] of RELEASED_ALIASES) { - it(`FE18/GX21z: a version-1 record naming ${what} resumes against its stated alias`, function* () { - const root = yield* useWorkspace(); - yield* writeTextFile(join(root, "notes.md"), "the retained note\n"); - const transport = yield* useTransport(() => ({ status: 200, body: "body" })); - - const first = yield* scoped(function* () { - yield* useWorkspaceFiles(root); - return yield* evaluate(candidate()); - }); - expect([what, first.failure]).toEqual([what, undefined]); - - // The same run, resumed against the record a released build wrote for - // this entry: the opaque string, no revision, the untagged policy shape. - const again = yield* scoped(function* () { - yield* useWorkspaceFiles(root); - return yield* evaluate(candidate(), { - stream: new InMemoryStream( - withVersionOne( - yield* partial(first.events).readAll(), - versionOneRecord(source, allow, allowed, requests), - ), - ), - }); - }); - - expect([what, again.failure]).toEqual([what, undefined]); - // Restored rather than decided a second time. - expect([what, admissions(again.events).length]).toEqual([what, 1]); - expect(transport.performed.length).toBeGreaterThanOrEqual(0); - }); - } - - it("FE18/GX21z: a version-1 record naming the workflow Dir resumes against its stated alias", function* () { - // The host-supplied half of the same contract. `@executablemd/workflow/ - // composition/dir-v2#Dir` is the workflow run's string, stated by the - // workflow entry rather than by core — and a journal holding it reconciles - // for exactly the same reason core's do. - const executed: string[] = []; - const candidate = () => - selecting(`held\n`, [], { - allow: ["write"], - mutations: [dirEntry(executed)], - }); - - const first = yield* evaluate(candidate()); - expect(first.failure).toBe(undefined); - expect(executed).toEqual(["dir:generated"]); - - const again = yield* evaluate(candidate(), { - stream: new InMemoryStream( - withVersionOne( - yield* partial(first.events).readAll(), - versionOneRecord( - `held\n`, - ["write"], - [ - { - name: "Dir", - identity: "@executablemd/workflow/composition/dir-v2#Dir", - forms: ["paired"], - }, - ], - ), - ), - ), - }); - - expect(again.failure).toBe(undefined); - expect(admissions(again.events)).toHaveLength(1); - }); - - it("FE18/GX21z: the standard core admission a released build wrote still resumes", function* () { - // The record is `bb2c1c49`'s own: core's read-only `` retained as the - // string that build committed, under the untagged policy shape. What lets - // it resume is not a spelling rule — there is none to find — but that - // `pinnedFileRead()` states that exact string as the version-1 identity it - // succeeds. - const root = yield* useWorkspace(); - yield* writeTextFile(join(root, "notes.md"), "the retained note\n"); - - const first = yield* scoped(function* () { - yield* useWorkspaceFiles(root); - return yield* evaluate(request(`\n`, [pinnedFileRead()])); - }); - expect(first.failure).toBe(undefined); - - const again = yield* scoped(function* () { - const reads = yield* useWorkspaceFiles(root); - const evaluated = yield* evaluate(request(`\n`, [pinnedFileRead()]), { - stream: new InMemoryStream( - withVersionOne( - yield* partial(first.events).readAll(), - versionOneStandard(`\n`), - ), - ), - }); - return { evaluated, reads: [...reads.performed] }; - }); - - expect(again.evaluated.failure).toBe(undefined); - // Restored rather than decided a second time: one record, carried over from - // the run that wrote it in the older shape. - expect(admissions(again.evaluated.events)).toHaveLength(1); - }); - - it("FE18/GX21z: an entry that states no alias refuses the record that named it", function* () { - // The same released record, against a run whose `` entry does not - // state that string. Reconciliation is an assertion the entry makes, so an - // entry that makes none is a new grant — and the safe answer to "is this - // the thing the record was admitted under" is the one that refuses. - const root = yield* useWorkspace(); - yield* writeTextFile(join(root, "notes.md"), "the retained note\n"); - const first = yield* scoped(function* () { - yield* useWorkspaceFiles(root); - return yield* evaluate(request(`\n`, [pinnedFileRead()])); - }); - - const silent: GeneratedObservation = { ...pinnedFileRead(), legacy: undefined }; - const again = yield* scoped(function* () { - const reads = yield* useWorkspaceFiles(root); - const evaluated = yield* evaluate(request(`\n`, [silent]), { - stream: new InMemoryStream( - withVersionOne( - yield* duringPreparation(first.events).readAll(), - versionOneStandard(`\n`), - ), - ), - }); - return { evaluated, reads: [...reads.performed] }; - }); - - expect(again.evaluated.failure).toContain("admitted under"); - // Ahead of the first generated effect: the refusal is the ceiling's, and - // the admitted read never reached the provider. - expect(again.reads).toEqual([]); - }); - - it("FE18/GX21z: an arbitrary version-1 host identity resumes against its stated alias", function* () { - const first = yield* evaluate(request("\n", [versionOneProbe([])])); + it("FE18/GX21z: a current record restores, and the admitted read performs once", function* () { + const first = yield* evaluate(request("\n", [probe()])); expect(first.output).toContain("probed"); - // The other half of what version 1 could hold: whatever string the host - // passed to `pinnedComponent`, retained verbatim. `urn:host:probe/v1` - // resembles no structural identity at all, which is why nothing but the - // entry's own stated alias could connect it to what this run admits. - // - // This is also the version-1 positive control for the `GX21y` table below: - // every row there is this record with one thing wrong, so without this each - // of them could be refusing because an untagged record never resumes at all. + // The positive control every refusal row below is measured against: the + // record this build writes, resumed against the entry it was admitted + // under, restores the decision and performs the work it authorized. const performed: string[] = []; - const again = yield* evaluate(request("\n", [versionOneProbe(performed)]), { + const again = yield* evaluate(request("\n", [countedProbe(performed)]), { stream: new InMemoryStream( - withVersionOne(yield* partial(first.events).readAll(), versionOneAdmission("\n")), + withAdmission(yield* partial(first.events).readAll(), currentAdmission("\n")), ), }); @@ -1956,69 +1753,61 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde expect(admissions(again.events)).toHaveLength(1); }); - it("FE18/GX21z: a version-1 string no entry lists as its own refuses", function* () { - const first = yield* evaluate(request("\n", [versionOneProbe([])])); - - // Reconciliation is a stated alias, not a waiver. This run's entry lists a - // different string, so the retained one names an entry it does not have. - // - // The probe still counts, so this says the component was not reached rather - // than that no counting probe was admitted. - const performed: string[] = []; - const moved: GeneratedObservation = { - ...versionOneProbe(performed), - legacy: ["urn:host:probe/v0"], - }; - const again = yield* evaluate(request("\n", [moved]), { - stream: new InMemoryStream( - withVersionOne( - yield* duringPreparation(first.events).readAll(), - versionOneAdmission("\n"), - ), - ), - }); - - expect(again.failure).toContain("admitted under"); - expect(performed).toEqual([]); - }); - - it("FE18/GX21z: a version-1 string never describes a component answer", function* () { - const first = yield* evaluate(request("\n", [versionOneProbe([])])); + it("FE18/GX21z: the same URL with an updated implementation resumes", function* () { + const first = yield* evaluate(request("\n", [probe()])); - // The component-answer arm did not exist when untagged records were - // written, so no version-1 string ever described one. Reading this string as - // though it might would compare a fragment's admission equal to an admission it - // never had — even though the origin, the key and the revision all match. + // What a revision used to refuse. The implementation behind the name is a + // different function with different output, offered under the same URL by + // the same declaration — and between executions that is an updated build of + // the component the admission named rather than a changed grant. const performed: string[] = []; - const answered: GeneratedObservation = { - ...versionOneProbe(performed), - identity: hostIdentity("test://probe", "Probe"), - }; - const again = yield* evaluate(request("\n", [answered]), { + const updated = 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 again"); + return "probed again"; + }, + }, + "component-answer", + ); + const again = yield* evaluate(request("\n", [updated]), { stream: new InMemoryStream( - withVersionOne( - yield* duringPreparation(first.events).readAll(), - versionOneAdmission("\n"), - ), + withAdmission(yield* partial(first.events).readAll(), currentAdmission("\n")), ), }); - expect(again.failure).toContain("admitted under"); - expect(performed).toEqual([]); + expect(again.failure).toBe(undefined); + expect(performed).toEqual(["probed again"]); }); - it("FE18/GX21x: a literal version-1 refusal replays as the refusal it recorded", function* () { + it("FE18/GX21x: a current refusal replays as the refusal it recorded", function* () { const first = yield* evaluate(request("\n", [probe()])); const performed: string[] = []; + const refused: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { type: "generated_xmd", name: "generated:turn-1", input: CURRENT_POLICY }, + result: { + status: "ok", + value: { version: 3, decision: "refused", construct: "block" }, + }, + }; const again = yield* evaluate(request("\n", [countedProbe(performed)]), { stream: new InMemoryStream( - withVersionOne(yield* duringPreparation(first.events).readAll(), versionOneRefusal()), + withAdmission(yield* duringPreparation(first.events).readAll(), refused), ), }); - // The refusal shape had no version member either, and restoring it means - // refusing again rather than re-deciding a fragment an older build declined. + // Restoring a refusal means refusing again rather than re-deciding a + // fragment this run already declined. expect(again.failure).toContain("executable code block"); expect(performed).toEqual([]); }); @@ -2026,8 +1815,8 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde /** * One admission whose policy and result carry whatever members a case wants. * - * Built as a literal rather than by rewriting a real record, so a case - * states the exact shape this build must refuse. + * Built as a literal rather than by rewriting a real record, so a case states + * the exact shape this build must refuse. */ function forgedAdmission(policy: JsonObject, extra: JsonObject = {}): DurableEvent { return { @@ -2037,9 +1826,10 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde result: { status: "ok", value: { + version: 3, decision: "admitted", source: "\n", - named: [{ name: "Probe", identity: "test://probe", form: "self-closing" }], + named: [{ name: "Probe", identity: PROBE_IDENTITY, form: "self-closing" }], policy, ...extra, }, @@ -2047,232 +1837,152 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde }; } - /** - * One version-2 admission whose identities are whatever a case wants. - * - * Version 2 is where the identity is the closed tagged record, so these are - * the cases that say what "closed" means: a value that is not that record is - * refused rather than read as whichever shape it most resembles. - */ - function forgedVersionTwo(identity: Json, namedIdentity: Json = identity): DurableEvent { - const policy: JsonObject = { - version: 2, - allow: ["read"], - workspace: { roots: [...ROOTS], selectedRoot: ROOTS[0] }, - // A version-2 record this build wrote would hold the trusted composition - // table ahead of the selection, so the control forges one that does. A row - // below moves part of the probe's identity and nothing else. + /** The probe's row, with whatever one case moves about it. */ + function forgedRow(overrides: JsonObject): JsonObject { + return { allowed: [ { ...COMPOSITION_RECORD }, - { name: "Probe", identity, forms: ["self-closing", "paired"] }, - ], - requests: [], - }; - return { - type: "yield", - coroutineId: "root", - description: { type: "generated_xmd", name: "generated:turn-1", input: policy }, - result: { - status: "ok", - value: { - version: 2, - decision: "admitted", - source: "\n", - named: [{ name: "Probe", identity: namedIdentity, form: "self-closing" }], - policy, + { + name: "Probe", + form: "self-closing", + identity: PROBE_IDENTITY, + kind: "component-answer", + effect: "read", + ...overrides, }, - }, + ], }; } - /** The identity this run actually states for ``, as journal data. */ - const PROBE_RECORD: JsonObject = { - kind: "component-answer", - origin: "test://probe", - key: "Probe", - revision: "1", - }; - /** - * Every version-2 identity shape this build refuses. + * Every retained record this build refuses, and what is wrong with each. * - * Separate from the version-1 table below because the two need different - * resumes: these records name `PROBE_RECORD`, so the run has to state that - * same component-answer identity, or every row would refuse on the kind - * rather than on what it says it is about. Their positive control is - * `forgedVersionTwo(PROBE_RECORD)`, which resumes under exactly that probe. + * Their positive control is the current record above: every row here is a + * record that would otherwise resume with one thing wrong, so a row that + * refuses is refusing on the defect it names rather than because a forged + * admission never resumes at all. */ - const HOSTILE_V2: readonly (readonly [string, DurableEvent])[] = [ - ["a version-2 identity written as a version-1 string", forgedVersionTwo("test://probe")], - [ - "a version-2 identity with no kind", - forgedVersionTwo({ - origin: "test://probe", - key: "Probe", - revision: "1", - }), - ], - [ - "a version-2 identity naming a kind this build does not have", - forgedVersionTwo({ - ...PROBE_RECORD, - kind: "registration", - }), - ], + const HOSTILE: readonly (readonly [string, DurableEvent])[] = [ + // The two shapes earlier builds wrote. Neither is readable here, and + // neither is migrated: an old record is refused before the admission it + // would have restored performs anything. + ["the untagged #369 shape", UNTAGGED_RECORD], + ["the tagged structural-identity shape", STRUCTURAL_RECORD], + ["a marker this build does not have", forgedAdmission({ ...CURRENT_POLICY, version: 99 })], [ - "a version-2 identity carrying an extra member", - forgedVersionTwo({ - ...PROBE_RECORD, - widened: true, - }), + "a policy carrying legacy top-level roots", + forgedAdmission({ ...CURRENT_POLICY, roots: [...ROOTS], selectedRoot: ROOTS[0] }), ], [ - "a version-2 identity whose revision is not a string", - forgedVersionTwo({ - ...PROBE_RECORD, - revision: 1, - }), - ], - ["a version-2 identity that is an array", forgedVersionTwo(["test://probe", "Probe", "1"])], - ["a version-2 identity that is null", forgedVersionTwo(null)], - // A closed record is closed in both directions: an extra member is refused - // above, and each missing one is refused here. `kind` has its own row, so - // these are the three that carry what the implementation actually is. - [ - "a version-2 identity with no origin", - forgedVersionTwo({ kind: "component-answer", key: "Probe", revision: "1" }), + "a policy carrying a member this build does not know", + forgedAdmission({ ...CURRENT_POLICY, widened: true }), ], + ["an empty class selection", forgedAdmission({ ...CURRENT_POLICY, allow: [] })], + ["one class selected twice", forgedAdmission({ ...CURRENT_POLICY, allow: ["read", "read"] })], [ - "a version-2 identity with no key", - forgedVersionTwo({ kind: "component-answer", origin: "test://probe", revision: "1" }), + "classes out of canonical order", + forgedAdmission({ ...CURRENT_POLICY, allow: ["write", "read"] }), ], + // The row is closed in both directions: an extra member is refused, and + // each missing one is refused too, because every one of them is a term a + // continuation is held to. [ - "a version-2 identity with no revision", - forgedVersionTwo({ kind: "component-answer", origin: "test://probe", key: "Probe" }), + "a row carrying an extra member", + forgedAdmission({ ...CURRENT_POLICY, ...forgedRow({ widened: true }) }), ], - // The kind is compared, not merely parsed. Everything else about this record - // is what this run states, and `capability` is a kind this build has — so a - // row that refuses here is refusing on the kind alone, which is the whole - // reason the kind travels inside the identity rather than beside it. [ - "a version-2 identity whose kind is the other one", - forgedVersionTwo({ ...PROBE_RECORD, kind: "capability" }), + "a row with no form", + forgedAdmission({ + ...CURRENT_POLICY, + allowed: [ + { ...COMPOSITION_RECORD }, + { + name: "Probe", + identity: PROBE_IDENTITY, + kind: "component-answer", + effect: "read", + }, + ], + }), ], - // One record, two shapes. The policy and the named invocation are read by - // different readers, and a record that satisfied one while contradicting the - // other would be a grant assembled out of two different versions. - // - // The first of these is the row that carries its own weight alone. A - // retained `named` list is *parsed* and never compared — the policy is what - // a continuation is held to — so the version strictness of that parse is the - // only thing standing behind it. Relaxing version 2 to accept a version-1 - // string reddens this row and neither of the others, because those are - // caught by the comparison as well. [ - "a version-2 policy beside a version-1 named identity", - forgedVersionTwo(PROBE_RECORD, V1_SPELLING), + "a row naming a form this build does not have", + forgedAdmission({ ...CURRENT_POLICY, ...forgedRow({ form: "either" }) }), ], [ - "a version-1 named identity beside a version-2 policy", - forgedVersionTwo(V1_SPELLING, PROBE_RECORD), + "a row naming an effect this build does not have", + forgedAdmission({ ...CURRENT_POLICY, ...forgedRow({ effect: "ask" }) }), ], - ]; - - /** Every retained shape this build refuses, and what is wrong with each. */ - const HOSTILE: readonly (readonly [string, DurableEvent])[] = [ - ["a version this build does not have", forgedAdmission({ ...V1_POLICY, version: 99 })], [ - "a version-1 policy carrying a version-2 workspace", - forgedAdmission({ - ...V1_POLICY, - workspace: { roots: [...ROOTS], selectedRoot: ROOTS[0] }, - }), + "a row naming a kind this build does not have", + forgedAdmission({ ...CURRENT_POLICY, ...forgedRow({ kind: "registration" }) }), ], + // The identity is a URL in the accepted namespace and nothing else. A + // relative reference, a look-alike origin, the structural record an earlier + // build wrote and a value that is not a string at all are each refused + // rather than read as whichever shape they most resemble. [ - "a version-2 policy still carrying legacy roots", - forgedAdmission({ - version: 2, - allow: ["read"], - roots: [...ROOTS], - selectedRoot: ROOTS[0], - allowed: V1_POLICY.allowed, - requests: [], - }), + "a row whose identity is relative", + forgedAdmission({ ...CURRENT_POLICY, ...forgedRow({ identity: "components/probe/read" }) }), ], [ - "a policy carrying a member this build does not know", + "a row whose identity names a look-alike origin", forgedAdmission({ - ...V1_POLICY, - widened: true, + ...CURRENT_POLICY, + ...forgedRow({ identity: "https://executable.md.example.com/components/probe/read" }), }), ], - ["an empty class selection", forgedAdmission({ ...V1_POLICY, allow: [] })], - ["one class selected twice", forgedAdmission({ ...V1_POLICY, allow: ["read", "read"] })], - ["classes out of canonical order", forgedAdmission({ ...V1_POLICY, allow: ["write", "read"] })], [ - "an allowed entry carrying an extra member", + "a row whose identity is a structural record", forgedAdmission({ - ...V1_POLICY, - allowed: [ - { - name: "Probe", - identity: "test://probe", - forms: ["self-closing"], - widened: true, - }, - ], + ...CURRENT_POLICY, + ...forgedRow({ + identity: { kind: "component-answer", origin: "test://probe", key: "Probe" }, + }), }), ], [ - "an allowed entry with no forms", - forgedAdmission({ - ...V1_POLICY, - allowed: [{ name: "Probe", identity: "test://probe", forms: [] }], - }), + "a row whose identity is an array", + forgedAdmission({ ...CURRENT_POLICY, ...forgedRow({ identity: [PROBE_IDENTITY] }) }), ], - ["a repeated retained root", forgedAdmission({ ...V1_POLICY, roots: [ROOTS[0], ROOTS[0]] })], - ["no retained roots at all", forgedAdmission({ ...V1_POLICY, roots: [], selectedRoot: "" })], [ - "a selected root the basis does not hold", - forgedAdmission({ - ...V1_POLICY, - selectedRoot: "workspace://never-retained", - }), + "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 request carrying a member this build does not know", forgedAdmission({ - ...V1_POLICY, + ...CURRENT_POLICY, requests: [{ url: URL_ONE, method: "GET", headers: {}, widened: true }], }), ], [ "a request whose timeout is not a number", forgedAdmission({ - ...V1_POLICY, + ...CURRENT_POLICY, requests: [{ url: URL_ONE, method: "GET", headers: {}, timeout: "soon" }], }), ], [ "a result carrying a member this build does not know", - forgedAdmission(V1_POLICY, { - widened: true, - }), + forgedAdmission(CURRENT_POLICY, { widened: true }), ], [ - "a tagged result holding an untagged policy", + "a result with no marker at all", { type: "yield", coroutineId: "root", - description: { type: "generated_xmd", name: "generated:turn-1", input: V1_POLICY }, + description: { type: "generated_xmd", name: "generated:turn-1", input: CURRENT_POLICY }, result: { status: "ok", value: { - version: 2, decision: "admitted", source: "\n", - named: [{ name: "Probe", identity: "test://probe", form: "self-closing" }], - policy: V1_POLICY, + named: [{ name: "Probe", identity: PROBE_IDENTITY, form: "self-closing" }], + policy: CURRENT_POLICY, }, }, }, @@ -2282,103 +1992,86 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde { type: "yield", coroutineId: "root", - description: { type: "generated_xmd", name: "generated:turn-1", input: V1_POLICY }, + description: { type: "generated_xmd", name: "generated:turn-1", input: CURRENT_POLICY }, result: { status: "ok", value: { + version: 3, decision: "admitted", source: "\n", named: [ { name: "Probe", - identity: "test://probe", + identity: PROBE_IDENTITY, form: "self-closing", widened: true, }, ], - policy: V1_POLICY, + policy: CURRENT_POLICY, + }, + }, + }, + ], + [ + "a named invocation whose identity is a legacy string", + { + type: "yield", + coroutineId: "root", + description: { type: "generated_xmd", name: "generated:turn-1", input: CURRENT_POLICY }, + result: { + status: "ok", + value: { + version: 3, + decision: "admitted", + source: "\n", + named: [{ name: "Probe", identity: "urn:host:probe/v1", form: "self-closing" }], + policy: CURRENT_POLICY, }, }, }, ], ]; - for (const [what, admission] of HOSTILE_V2) { - it(`FE18/GX21y: a retained record with ${what} refuses before any effect`, function* () { - // Resumed under the component-answer probe these records name, so a row - // refuses on the defect it is about rather than on a kind that never - // matched. The control below resumes on the unmodified record. - const first = yield* evaluate(request("\n", [probe()])); - - const performed: string[] = []; - const again = yield* evaluate(request("\n", [countedProbe(performed)]), { - stream: new InMemoryStream( - withVersionOne(yield* duringPreparation(first.events).readAll(), admission), - ), - }); - - expect([what, again.failure !== undefined]).toEqual([what, true]); - expect([what, performed]).toEqual([what, []]); + /** 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* () { - // The version-1 probe, because these rows are `V1_POLICY` with one thing - // wrong: the base has to be a record that *would* resume, or each row - // passes because an untagged record never resumes rather than because of - // the defect it names. `GX21z` is that control. - const first = yield* evaluate(request("\n", [versionOneProbe([])])); + const first = yield* evaluate(request("\n", [probe()])); const performed: string[] = []; - const again = yield* evaluate(request("\n", [versionOneProbe(performed)]), { + const again = yield* evaluate(request("\n", [countedProbe(performed)]), { stream: new InMemoryStream( - withVersionOne(yield* duringPreparation(first.events).readAll(), admission), + withAdmission(yield* duringPreparation(first.events).readAll(), admission), ), }); // A record this build cannot read whole is not a grant. Reading the - // members it recognizes would admit one on terms it never saw. + // members it recognizes would admit one on terms it never saw, and the + // refusal lands before the admitted read reaches a provider. expect([what, again.failure !== undefined]).toEqual([what, true]); expect([what, performed]).toEqual([what, []]); }); } - it("FE18/GX21y: the same forged record with the exact identity resumes", function* () { - const first = yield* evaluate(request("\n", [probe()])); - - // The positive control for the structural-identity rows above. Every one of - // them is this record with one part of the identity moved, so this is what - // proves each refuses for its own reason rather than because a forged - // version-2 admission never resumes at all. - const performed: string[] = []; - const again = yield* evaluate(request("\n", [countedProbe(performed)]), { - stream: new InMemoryStream( - withVersionOne(yield* partial(first.events).readAll(), forgedVersionTwo(PROBE_RECORD)), - ), - }); - - expect(again.failure).toBe(undefined); - expect(performed).toEqual(["probed"]); - // Restored rather than decided a second time. - expect(admissions(again.events)).toHaveLength(1); - }); - it("GX21: a changed identity behind the same name refuses before invoking it", function* () { const first = yield* evaluate(request("\n", [probe()])); expect(first.output).toContain("probed"); const again = yield* evaluate( - request("\n", [ - pinnedComponent("Probe", hostIdentity("test://other", "Probe"), OTHER), - ]), + request("\n", [pinnedComponent("Probe", OTHER, "component-answer")]), { stream: duringPreparation(first.events) }, ); expect(again.failure).toContain("admitted under"); // Fixed: which identity moved is exactly what a refusal must not publish. - expect(again.failure).not.toContain("test://other"); - expect(again.failure).not.toContain("test://probe"); + expect(again.failure).not.toContain("components/other/read"); + expect(again.failure).not.toContain(PROBE_IDENTITY); expect(String(again.output ?? "")).not.toContain("the other implementation ran"); expect(persisted(again.events)).not.toContain("the other implementation ran"); }); @@ -2387,9 +2080,7 @@ describe("Tier GX — a resumed run is held to the ceilings it was admitted unde const first = yield* evaluate(request("\n", [probe()])); const again = yield* evaluate( - request("\n", [ - pinnedComponent("Probe", hostIdentity("test://other", "Probe"), OTHER), - ]), + request("\n", [pinnedComponent("Probe", OTHER, "component-answer")]), { stream: partial(first.events) }, ); @@ -2624,13 +2315,14 @@ describe("Tier GX — the secret gate covers what is retained", () => { if (admission === undefined || observation === undefined) { throw new Error("the gate was not given both events"); } - // The identity travels as its four members, so the gate sees the origin and - // the key as themselves rather than as one assembled spelling. + // The whole retained admission reaches the gate before it persists: the + // candidate's own request, the Workspace basis, and the URL the admitted + // spelling runs under beside the effect table that admitted it. for (const marker of [ "url-marker", "workspace://primary", - "@executablemd/core", - '"key":"Fetch"', + "https://executable.md/components/fetch/read", + '"effect":"read"', ]) { expect(admission).toContain(marker); } @@ -2741,13 +2433,17 @@ describe("Tier WGAC — the pinned read-only File", () => { expect(admittedFragments(attempt.evaluated.events)).toHaveLength(1); }); - it("WGAC1: the read identity is not the unconstrained File identity", function* () { - // A retained admission resumes only under the identity it was granted with, - // and the comparison is on this string. - // Not the unconstrained ``: the read form is a key of its own, so a - // run that later admitted the unconstrained one states a different identity. - expect(pinnedFileRead().identity.key).toBe("File:read"); + it("WGAC1: the read entry admits the reading spelling and nothing else", function* () { + // A retained admission resumes only under the identity the admitted + // spelling runs under, and ``'s two spellings are offered under two + // URLs: the reading form observes, the writing form mutates. So this entry + // is the self-closing one, and a run that admitted the paired spelling + // would be stating a different grant. expect(pinnedFileRead().selfClosing).toBe(true); + expect(pinnedFileRead().definition.generated).toEqual({ + "self-closing": { identity: "https://executable.md/components/file/read", effect: "read" }, + paired: { identity: "https://executable.md/components/file/write", effect: "write" }, + }); }); it("WGAC13: the pinned deletion is core's own definition, in its one form", function* () { @@ -2755,17 +2451,15 @@ describe("Tier WGAC — the pinned read-only File", () => { expect(deletion.name).toBe("File.Delete"); // What a retained admission is compared against, and what a continuation - // that selected the write table is held to. - expect(deletion.identity).toEqual({ - kind: "capability", - origin: "@executablemd/core", - key: "File.Delete", - revision: "2", - }); - // One name, one identity: the component answers the self-closing spelling - // and refuses the paired one, so this states what the identity is rather - // than narrowing it — and stating it is what decides a paired spelling in - // preflight, before the fragment's first effect. + // that selected the write table is held to: the URL core's own declaration + // offers this spelling under, and the effect it offers it as. + expect(deletion.definition.generated).toEqual({ + "self-closing": { identity: "https://executable.md/components/file/delete", effect: "write" }, + }); + // One name, one form: the component answers the self-closing spelling and + // refuses the paired one, so this states the whole of what core offers + // rather than narrowing it — and stating it is what decides a paired + // spelling in preflight, before the fragment's first effect. expect(deletion.form).toBe("self-closing"); // The exact object core's registry holds, not one shaped like it. Expansion // invokes a copy of this, and a same-shaped substitute is a different @@ -2800,10 +2494,8 @@ describe("Tier WGAC — the pinned read-only File", () => { admitted?.type === "yield" && admitted.result.status === "ok" ? admitted.result.value : undefined; - expect(JSON.stringify(named)).toContain(JSON.stringify(pinnedFileRead().identity)); - expect(JSON.stringify(named)).toContain( - JSON.stringify(pinnedFetch([ADMITTED_REQUEST]).identity), - ); + expect(JSON.stringify(named)).toContain("https://executable.md/components/file/read"); + expect(JSON.stringify(named)).toContain("https://executable.md/components/fetch/read"); // Ordinary composition: the File read renders its text, and the Fetch — // which returns a value rather than text — renders nothing at all. Its // response reached the fragment and the fragment did not ask for it, so @@ -2897,13 +2589,17 @@ const NEST: FunctionComponentDefinition = { kind: "function", name: "Nest", props: { type: "object", properties: {}, additionalProperties: false }, + generated: { + "self-closing": { identity: "https://executable.md/components/nest/read", effect: "read" }, + paired: { identity: "https://executable.md/components/nest/write", effect: "write" }, + }, *fn(): Operation { return yield* content(); }, }; function nest(form: GeneratedComponentForm = "paired"): GeneratedMutation { - return pinnedMutation("Nest", hostIdentity("test://nest", "Nest"), NEST, form); + return pinnedMutation("Nest", NEST, form, "component-answer"); } /** One candidate, with the classes and the write table a run states for it. */ @@ -2979,10 +2675,18 @@ describe("Tier GXC — a selection is not a grant", () => { ...COMPOSITION, { name: "Probe", - identity: hostIdentity("test://probe", "Probe"), - forms: ["self-closing", "paired"], + form: "self-closing", + identity: PROBE_IDENTITY, + kind: "component-answer", + effect: "read", + }, + { + name: "Nest", + form: "paired", + identity: "https://executable.md/components/nest/write", + kind: "component-answer", + effect: "write", }, - { name: "Nest", identity: hostIdentity("test://nest", "Nest"), forms: ["paired"] }, ], }); }); @@ -3027,7 +2731,10 @@ describe("Tier GXC — a selection is not a grant", () => { const attempt = yield* evaluate( selecting("x\n", [probe()], { allow: ["read", "write"], - mutations: [nest("paired"), nest("either")], + mutations: [ + pinnedMutation("Writes", WRITES, "paired", "component-answer"), + pinnedMutation("Writes", WRITES, "either", "component-answer"), + ], }), ); @@ -3039,9 +2746,7 @@ describe("Tier GXC — a selection is not a grant", () => { const attempt = yield* evaluate( selecting("\n", [probe()], { allow: ["read", "write"], - mutations: [ - pinnedMutation("Probe", hostIdentity("test://probe-write", "Probe"), NEST, "paired"), - ], + mutations: [pinnedMutation("Probe", NEST, "paired", "component-answer")], }), ); @@ -3074,8 +2779,16 @@ describe("Tier GXC — a name is not a form", () => { expect(attempt.evaluated.failure).toBe(undefined); // One name, two identities, chosen by how each element was written. expect(recordedNames(admittedFragments(attempt.evaluated.events)[0])).toEqual([ - { name: "File", identity: pinnedFileRead().identity, form: "self-closing" }, - { name: "File", identity: pinnedFileWrite().identity, form: "paired" }, + { + name: "File", + identity: "https://executable.md/components/file/read", + form: "self-closing", + }, + { + name: "File", + identity: "https://executable.md/components/file/write", + form: "paired", + }, ]); expect(attempt.files).toEqual(["notes.md", "write:proposed.md"]); expect(yield* readTextFile(join(root, "proposed.md"))).toBe("the fragment wrote this"); @@ -3322,16 +3035,14 @@ describe("Tier GXC — a resumed run is held to its classes and forms", () => { "a widened admitted form", { allow: ["write"], - mutations: [pinnedMutation("File", pinnedFileWrite().identity, NEST, "either")], + mutations: [pinnedMutation("File", WRITES, "either", "component-answer")], }, ], [ "a replaced write identity", { allow: ["write"], - mutations: [ - pinnedMutation("File", hostIdentity("test://other-write", "File"), NEST, "paired"), - ], + mutations: [pinnedMutation("File", NEST, "paired", "component-answer")], }, ], ["an added write identity", { allow: ["write"], mutations: [pinnedFileWrite(), nest()] }], @@ -3392,9 +3103,7 @@ describe("Tier GXC — a resumed run is held to its classes and forms", () => { const again = yield* evaluate( selecting("\n", [probe()], { allow: ["read"], - mutations: [ - pinnedMutation("Nest", hostIdentity("test://replaced", "Nest"), NEST, "paired"), - ], + mutations: [pinnedMutation("Nest", NEST, "paired", "component-answer")], }), { stream: duringPreparation(first.events) }, ); @@ -3433,14 +3142,20 @@ describe("Tier GXC — the authored form survives the public content chain", () /** A read component that reports what the chain answers, so a lie is visible. */ function says(): GeneratedObservation { - return pinnedComponent("Says", hostIdentity("test://says", "Says"), { - kind: "function", - name: "Says", - props: { type: "object", properties: {}, additionalProperties: false }, - *fn(): Operation { - return `says:${yield* hasContent()}`; + return pinnedComponent( + "Says", + { + kind: "function", + name: "Says", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: offers("says/read", "read"), + *fn(): Operation { + return `says:${yield* hasContent()}`; + }, }, - }); + "component-answer", + ); } const REPORTING: Array<[string, readonly boolean[]]> = [ @@ -3481,8 +3196,16 @@ describe("Tier GXC — the authored form survives the public content chain", () expect(String(attempt.evaluated.output)).toContain("the retained note"); // And the admission still names the identity and form it was granted for. expect(recordedNames(admittedFragments(attempt.evaluated.events)[0])).toEqual([ - { name: "Says", identity: hostIdentity("test://says", "Says"), form: "self-closing" }, - { name: "File", identity: pinnedFileRead().identity, form: "self-closing" }, + { + name: "Says", + identity: "https://executable.md/components/says/read", + form: "self-closing", + }, + { + name: "File", + identity: "https://executable.md/components/file/read", + form: "self-closing", + }, ]); }); } @@ -3524,8 +3247,16 @@ describe("Tier GXC — the authored form survives the public content chain", () expect(attempt.files).toEqual(["write:proposed.md"]); expect(yield* readTextFile(join(root, "proposed.md"))).toBe("the fragment wrote this"); expect(recordedNames(admittedFragments(attempt.evaluated.events)[0])).toEqual([ - { name: "Says", identity: hostIdentity("test://says", "Says"), form: "self-closing" }, - { name: "File", identity: pinnedFileWrite().identity, form: "paired" }, + { + name: "Says", + identity: "https://executable.md/components/says/read", + form: "self-closing", + }, + { + name: "File", + identity: "https://executable.md/components/file/write", + form: "paired", + }, ]); }); } diff --git a/packages/core/tests/shared-operation-permissions.test.ts b/packages/core/tests/shared-operation-permissions.test.ts index 11b41ff14..76dd08abb 100644 --- a/packages/core/tests/shared-operation-permissions.test.ts +++ b/packages/core/tests/shared-operation-permissions.test.ts @@ -104,11 +104,7 @@ function ceiling(): ExecutionInstallation { return { evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], - write: [ - fileWriteEntry(), - directoryEntry({ origin: "test://host", key: "Dir", revision: "1" }, "Dir"), - fileDeleteEntry(), - ], + write: [fileWriteEntry(), directoryEntry("Dir"), fileDeleteEntry()], }, }; } diff --git a/packages/core/tests/support/answer-provider.ts b/packages/core/tests/support/answer-provider.ts index f3d1ed435..14a591e7d 100644 --- a/packages/core/tests/support/answer-provider.ts +++ b/packages/core/tests/support/answer-provider.ts @@ -2,10 +2,10 @@ * A trusted host's provider for one `component-answer` entry. * * The shape a real provider has: it composes `Component.importComponent` - * middleware for its own name, and states an identity for the exact object it - * returns using the request canonical execution minted for that handler - * invocation. It holds no definition the profile could read, because the - * profile arm it backs has nowhere to put one. + * middleware for its own name, and publishes the exact object it returns + * through the request canonical execution minted for that handler invocation. + * It holds no definition the profile could read, because the profile arm it + * backs has nowhere to put one. * * Every deviation a row needs is a member here rather than a second provider, * so what one case changes about the honest one is visible in the case. @@ -18,6 +18,7 @@ import type { ComponentAnswerRegistrar, ComponentAnswerRequest, } from "../../host.ts"; +import type { GeneratedEffect, GeneratedForms } from "../../src/components/generated-metadata.ts"; import type { FunctionComponentDefinition, Json, PropsSchema } from "../../src/types.ts"; /** The contract an implementation states when a row does not care what it is. */ @@ -29,11 +30,20 @@ export interface Implementation { readonly invoked: string[]; } -/** One named implementation that records each time it is entered. */ +/** + * One named implementation that records each time it is entered. + * + * It offers generated use of its self-closing form, because that is what a + * provider's own declaration states and what an admitted entry reads: the + * identity and the effect are on the definition rather than beside it. A row + * that needs other metadata passes its own, and one that needs an + * implementation offering no generated use at all passes `null`. + */ export function implementation( name: string, label: string, props: PropsSchema = NO_PROPS, + generated: GeneratedForms | null = offering(name, "read"), ): Implementation { const invoked: string[] = []; return { @@ -42,6 +52,8 @@ export function implementation( kind: "function", name, props, + forms: ["self-closing"], + ...(generated === null ? {} : { generated }), // deno-lint-ignore require-yield *fn(): Operation { invoked.push(label); @@ -51,22 +63,33 @@ export function implementation( }; } +/** What a provider's definition offers one spelling of `name` as. */ +export function offering( + name: string, + effect: GeneratedEffect, + form: "self-closing" | "paired" = "self-closing", +): GeneratedForms { + return { + [form]: { + identity: `https://executable.md/components/${name.toLowerCase()}/${effect}`, + effect, + }, + }; +} + /** How one row wants its provider to differ from the honest one. */ export interface ProviderOptions { - /** The provider's own name for itself; the origin every identity statement carries. */ + /** The provider's own name for itself; the provenance every publication carries. */ readonly origin?: string; - /** The key and revision this provider states. */ - readonly key?: string; - readonly revision?: string; /** Every lookup this provider *answered*, in order. */ readonly lookups?: string[]; /** Every lookup this provider was asked at all, answered or delegated. */ readonly asked?: string[]; - /** Return the answer without stating an identity for it at all. */ - readonly unclaimed?: boolean; - /** State the identity, then answer with a copy of the identified object. */ + /** Return the answer without publishing it at all. */ + readonly unpublished?: boolean; + /** Publish the definition, then answer with a copy of the published object. */ readonly copied?: boolean; - /** State the identity, then edit the identified object. */ + /** Publish the definition, then edit the published object. */ readonly mutated?: boolean; /** * Keep answering after the capture, rather than settling. @@ -77,52 +100,51 @@ export interface ProviderOptions { */ readonly keepsAnswering?: boolean; /** - * Keep *stating identities* after the capture, rather than settling. + * Keep *publishing* after the capture, rather than settling. * * The other half of the same refusal, one step earlier: the resolution this - * provider answered has settled, so there is no open window to state an - * identity into and recording the identity refuses before the witness is - * ever consulted. + * provider answered has settled, so there is no open window to publish into, + * and publishing refuses before the witness is ever consulted. */ - readonly reclaimsLater?: boolean; + readonly publishesLater?: boolean; /** - * Delegate first, and answer with this provider's own identified object. + * Delegate first, and answer with this provider's own published object. * * What an *outer* provider does. The one installed first composes outermost, * so it sees the chain's answer before deciding, and returning its own - * identified object is an honest substitution: the identity travels with the - * object the chain finally gives back. + * published object is an honest substitution: the publication travels with + * the object the chain finally gives back. */ readonly delegatesFirst?: boolean; /** - * Delegate and answer with whatever came back, stating no identity. + * Delegate and answer with whatever came back, publishing nothing. * * What a *repeated* bootstrap does. A package entered through an inherited * layer and again through a local one composes twice, and the outer entry - * cannot state a second identity for the object the inner one already - * identified — one implementation states what it is once. So it observes and - * passes the answer through, which is what keeps the layering additive. + * cannot publish the object the inner one already published — one + * implementation is published once. So it observes and passes the answer + * through, which is what keeps the layering additive. */ readonly observesOnly?: boolean; /** What delegating answered with, for a row that has to prove it happened. */ readonly delegated?: unknown[]; /** - * Run this inside the live resolution, just before stating the identity. + * Run this inside the live resolution, just before publishing. * - * The one place a row can reach that is *during* an open identity-recording - * window for this name — which is what a race between two resolutions has to - * be observed from, since there is no other way to be inside one. + * The one place a row can reach that is *during* an open publication window + * for this name — which is what a race between two resolutions has to be + * observed from, since there is no other way to be inside one. */ readonly whileResolving?: (request: ComponentAnswerRequest) => void; /** * Answer with a value whose contract reads differently each time it is read. * * The check/use gap at execution scale. `[[Get]]` alternates between the - * identified schema and a substitution; comparing own descriptors, which is - * how the identity is checked, does not run the trap. So a capture that + * published schema and a substitution; comparing own descriptors, which is + * how a publication is checked, does not run the trap. So a capture that * checked the answer and then *read it again* to take its copy would seal * the substitution — and a fragment would be validated against a contract - * the provider never identified. + * the provider never published. */ readonly alternating?: { readonly substitute: PropsSchema; readonly reads: string[] }; /** Where this provider leaves each request its handler received, for a row to keep. */ @@ -130,8 +152,8 @@ export interface ProviderOptions { } /** - * One answer whose `props` alternates between what was identified and - * something else, read by read. + * One answer whose `props` alternates between what was published and something + * else, read by read. * * Only `[[Get]]` is trapped, because that is what taking a copy of a definition * uses and what comparing own descriptors does not. The schema is the member to @@ -174,8 +196,6 @@ export function answerProvider( options: ProviderOptions = {}, ): ComponentAnswerInstallation { const origin = options.origin ?? "test://provider"; - const key = options.key ?? name; - const revision = options.revision ?? "1"; let settled = false; return { origin, @@ -191,14 +211,14 @@ export function answerProvider( options.retain?.push(request); options.asked?.push(asked); if (settled) { - if (options.reclaimsLater) { - // Still trying to *identify* an answer after its resolution - // settled. There is no window open for this name, so the claim - // itself is what refuses. - return request.claim(answer, { key, revision }); + if (options.publishesLater) { + // Still trying to publish an answer after its resolution settled. + // There is no window open for this name, so the publication itself + // is what refuses. + return request.answer(answer); } if (options.keepsAnswering) { - // Still answering, without claiming. Whatever this returns is not + // Still answering, without publishing. Whatever this returns is not // the object canonical execution issued for the import that asked, // so the witness is what refuses. return answer; @@ -215,30 +235,31 @@ export function answerProvider( if (options.delegatesFirst) { // An outer provider sees the chain's answer before deciding. What it // does with it is a row's business; recording it is what lets a row - // say the inner half genuinely answered — and claiming *after* + // say the inner half genuinely answered — and publishing *after* // delegating is what an outer replacement has to be able to do. options.delegated?.push(yield* next()); } options.whileResolving?.(request); - if (options.unclaimed) { + if (options.unpublished) { return answer; } const supplied = options.alternating === undefined ? answer : alternatingAnswer(answer, options.alternating.substitute, options.alternating.reads); - const claimed = request.claim(supplied, { key, revision }); + const published = request.answer(supplied); if (options.copied) { // The outer-replacement case: a handler further out returns its own - // object, so the claim does not travel with the name. - return { ...claimed }; + // object, so the publication does not travel with the name — and + // copying the generated metadata with it changes nothing. + return { ...published }; } if (options.mutated) { - // The same object, edited after the claim. What was claimed is no + // The same object, edited after publication. What was published is no // longer what is there. - Object.assign(claimed, { name: `${name}Substituted` }); + Object.assign(published, { name: `${name}Substituted` }); } - return claimed; + return published; }); }, }; diff --git a/packages/core/tests/syntax-component.test.ts b/packages/core/tests/syntax-component.test.ts index 80b9f7c7c..1f3846f78 100644 --- a/packages/core/tests/syntax-component.test.ts +++ b/packages/core/tests/syntax-component.test.ts @@ -1014,18 +1014,9 @@ describe("Tier SYN — the named form", () => { */ const PROBE_LABEL = "probe ran"; - /** The provider-backed entry a layered host admits, at the identity it admits. */ - function layeredEntry(revision = "1"): FragmentEvaluationInput { - return { - read: [ - { - kind: "component-answer", - name: "Open", - identity: { origin: "test://layer", key: "Open", revision }, - forms: ["self-closing"], - }, - ], - }; + /** The provider-backed entry a layered host admits. */ + function layeredEntry(): FragmentEvaluationInput { + return { read: [{ kind: "component-answer", name: "Open", forms: ["self-closing"] }] }; } /** The registration the inner layer adds beside the bootstrap's own. */ @@ -1121,7 +1112,7 @@ describe("Tier SYN — the named form", () => { } let refused: unknown; try { - held.claim(implementation("Open", "late").definition, { key: "Open", revision: "1" }); + held.answer(implementation("Open", "late").definition); } catch (error) { refused = error; } @@ -1164,14 +1155,16 @@ describe("Tier SYN — the named form", () => { expect(answer.invoked).toEqual([]); }); - it("FE30: a provider identity the layered host did not admit refuses", function* () { + it("FE30: a provider answer offering no admitted spelling refuses", function* () { // The other refusal the provider half adds. The layering is honest and the - // bootstrap's documentation and registrations are intact; what moved is the - // revision the local provider claims. An admitted identity is the exact - // implementation, so this refuses at capture — before the root import, and - // therefore before any registration resolves or any fragment work happens. + // bootstrap's documentation and registrations are intact; what the local + // provider answers with is a definition whose own declaration offers no + // generated use of the spelling this host admitted. There is no identity + // for a continuation to be compared against, so this refuses at capture — + // before the root import, and therefore before any registration resolves or + // any fragment work happens. const probed: string[] = []; - const answer = implementation("Open", "the layered answer ran"); + const answer = implementation("Open", "the layered answer ran", undefined, null); const failed = yield* refusal( scoped(function* () { @@ -1183,12 +1176,9 @@ describe("Tier SYN — the named form", () => { return yield* run(`\n\n\\n'} allow={["read"]} />\n`, [ stating(symbolsOf("Marker")).installation, { - evaluation: layeredEntry("1"), + evaluation: layeredEntry(), componentAnswers: [ - answerProvider("Open", answer.definition, { - origin: "test://layer", - revision: "2", - }), + answerProvider("Open", answer.definition, { origin: "test://layer" }), ], }, ]); @@ -1196,7 +1186,7 @@ describe("Tier SYN — the named form", () => { }), ); - expect(failed).toContain("the exact implementation"); + expect(failed).toContain("offers no generated use of it"); expect(answer.invoked).toEqual([]); expect(probed).toEqual([]); }); diff --git a/packages/core/tests/syntax-loaded-copy.test.ts b/packages/core/tests/syntax-loaded-copy.test.ts index a2a4f6204..0898b9f91 100644 --- a/packages/core/tests/syntax-loaded-copy.test.ts +++ b/packages/core/tests/syntax-loaded-copy.test.ts @@ -245,17 +245,9 @@ describe("Tier SYN — a separately loaded protected implementation", () => { }; const projected: unknown[] = []; const observed: string[] = []; - let captured = false; const profile: ExecutionInstallation = { evaluation: { - read: [ - { - kind: "component-answer", - name: "Syntax", - identity: { origin: "test://delegate", key: "Syntax", revision: "1" }, - forms: ["self-closing"], - }, - ], + read: [{ kind: "component-answer", name: "Syntax", forms: ["self-closing"] }], }, componentAnswers: [ { @@ -266,11 +258,11 @@ describe("Tier SYN — a separately loaded protected implementation", () => { if (request.name === "Syntax" && answer.kind === "function") { observed.push(request.name); projected.push(installed.componentRouting.project(answer.fn, forged)); - if (!captured) { - captured = true; - return request.claim(answer, { key: "Syntax", revision: "1" }); - } } + // Delegated unchanged, so canonical core's own publication of the + // protected answer travels with it. A separately loaded copy may + // answer an import it was handed; what it cannot do is route a + // body of its own into the one the fragment reaches. return answer; }); }, diff --git a/packages/git/src/composition/definitions.ts b/packages/git/src/composition/definitions.ts index a094494f8..5f280cb3a 100644 --- a/packages/git/src/composition/definitions.ts +++ b/packages/git/src/composition/definitions.ts @@ -1,12 +1,12 @@ /** - * This boundary's origin, and the generated-XMD identity it pins for ``. + * This boundary's origin, and the `` entry a workflow run admits. * - * The component itself is canonical core's - * (`core/src/components/fixed-filesystem.ts`). What stays here is the *identity* - * a workflow run's generated-XMD write table admits it under - * (specs/workflow-workspace-spec.md §8.4): a host states which grant a fragment - * is being given, and this host's grant is a string released builds already - * wrote into their journals. + * The component is canonical core's + * (`core/src/components/fixed-filesystem.ts`), and so is what admitting 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 + * an ordinary run has none. */ import { directoryEntry } from "@executablemd/core/host"; @@ -14,26 +14,14 @@ import type { FragmentEntry } from "@executablemd/core/host"; export const COMPOSITION_ORIGIN = "@executablemd/workflow/composition"; -/** - * The origin released builds retained for ``'s generated-XMD entry. - * - * Written out rather than derived from this package's own name. It identifies - * *retained history*: every journal a released build wrote holds this string, - * and a continuation compares the write table position by position. The - * component moved packages; what a run already recorded did not. - */ -export const RETAINED_DIRECTORY_ORIGIN = "@executablemd/workflow/composition"; - /** * ``'s entry in a workflow run's generated-XMD write table. * * A host assembling the generated-evaluation profile asks for this rather than - * writing the identity out again. Core supplies the body behind it, so this - * names no definition of its own: what the grant admits is the one `` every - * authored element reaches. + * 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. */ export function gitDirectoryEntry(): FragmentEntry { - return directoryEntry({ origin: RETAINED_DIRECTORY_ORIGIN, key: "Dir", revision: "3" }, "Dir", [ - "@executablemd/workflow/composition/dir-v2#Dir", - ]); + return directoryEntry("Dir"); } diff --git a/packages/workflow/src/deno/workspace/evaluate.ts b/packages/workflow/src/deno/workspace/evaluate.ts index d57bbefa1..1bc52de5d 100644 --- a/packages/workflow/src/deno/workspace/evaluate.ts +++ b/packages/workflow/src/deno/workspace/evaluate.ts @@ -18,7 +18,9 @@ * 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 identities, forms and ceilings rather than operations. + * 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. */ import { timeoutFetch } from "@executablemd/runtime"; @@ -49,9 +51,9 @@ export interface GeneratedEvaluationOptions { /** * The exact HTTP reads an admitted fragment may perform. * - * Empty admits `` not at all — the pinned identity is simply not on - * the allowlist, which is a different thing from admitting it and refusing - * every request. + * 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. */ readonly requests?: readonly GeneratedRequest[]; /** Further read components this host admits beside core's pinned ones. */ diff --git a/packages/workflow/src/generated-observations.ts b/packages/workflow/src/generated-observations.ts index c15aa7fc1..2cbe35b09 100644 --- a/packages/workflow/src/generated-observations.ts +++ b/packages/workflow/src/generated-observations.ts @@ -4,7 +4,7 @@ * * 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 identity, invoking only those, and recording one durable admission + * 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. * @@ -19,10 +19,10 @@ * 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 identity simply is not on the allowlist, + * `` whatsoever: the pinned entry simply is not on the allowlist, * which is a different thing from admitting it and refusing every request; - * - the read identities this run adds beyond core's own; and - * - the mutation identities this run is willing to expose at all. They are + * - 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. @@ -55,15 +55,15 @@ export interface GeneratedEvaluationPolicy { readonly selectedRoot: string; /** The exact HTTP reads a fragment may perform. Empty admits `` not at all. */ readonly requests: readonly GeneratedRequest[]; - /** The read identities this run states, beside the bounded `` below. */ + /** The read entries this run states, beside the bounded `` below. */ readonly reads: readonly GeneratedObservation[]; - /** The mutation identities this run states. Absent admits `write` not at all. */ + /** 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 identities one policy resolves to, in the order a run states them. */ +/** 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) { diff --git a/packages/workflow/tests/generated-agent-component.test.ts b/packages/workflow/tests/generated-agent-component.test.ts index 4b9a3bfe4..095af6996 100644 --- a/packages/workflow/tests/generated-agent-component.test.ts +++ b/packages/workflow/tests/generated-agent-component.test.ts @@ -387,13 +387,20 @@ function* plant(database: WorkflowRunDatabase, path: string, content: string): O } /** - * One capability identity, in the closed structural shape a run retains. + * One admitted spelling, as the row a run retains. * * Written out here rather than imported from the profile, so a row states what - * the journal must hold rather than agreeing with whatever the profile computed. + * the journal must hold rather than agreeing with whatever the profile + * computed. The identity is the URL the declaration that owns the component + * offers that spelling under. */ -function capability(origin: string, key: string, revision: string): Record { - return { kind: "capability", origin, key, revision }; +function row( + name: string, + form: "self-closing" | "paired", + identity: string, + effect: "read" | "write" | "composition", +): Record { + return { name, form, identity, kind: "capability", effect }; } describe("Tier WGAC — the registered Evaluate component", () => { @@ -980,11 +987,7 @@ describe("Tier WGAC — the registered Evaluate component", () => { * not the implementation's package. The profile supplies no directory entry * of its own. */ -const HOST_DIRECTORY: FragmentEntry = directoryEntry( - { origin: "@executablemd/workflow/composition", key: "Dir", revision: "3" }, - "Dir", - ["@executablemd/workflow/composition/dir-v2#Dir"], -); +const HOST_DIRECTORY: FragmentEntry = directoryEntry("Dir"); /** * Tier WGAC — the effect classes `` selects between @@ -1103,26 +1106,10 @@ describe("Tier WGAC — the standard write table", () => { expect(policy?.allowed).toEqual([ // The trusted composition table leads every policy, whatever the // selection asked for. - { - name: "Json", - identity: capability("@executablemd/core", "Json", "2"), - forms: ["self-closing"], - }, - { - name: "File", - identity: capability("@executablemd/core", "File:write", "2"), - forms: ["paired"], - }, - { - name: "Dir", - identity: capability("@executablemd/workflow/composition", "Dir", "3"), - forms: ["paired"], - }, - { - name: "File.Delete", - identity: capability("@executablemd/core", "File.Delete", "2"), - forms: ["self-closing"], - }, + 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.Delete", "self-closing", "https://executable.md/components/file/delete", "write"), ]); }); }); @@ -1164,7 +1151,7 @@ describe("Tier WGAC — the standard write table", () => { // 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({ origin: "tier-wgac", key: "Dir", revision: "1" }, "Dir"), + directory: directoryEntry("Dir"), }); expect(attempt.failure).toBe(undefined); @@ -1175,26 +1162,10 @@ describe("Tier WGAC — the standard write table", () => { // continuation compares one directory grant and not two. const policy = policyOf(admissions(attempt.events)[0]!); expect(policy?.allowed).toEqual([ - { - name: "Json", - identity: capability("@executablemd/core", "Json", "2"), - forms: ["self-closing"], - }, - { - name: "File", - identity: capability("@executablemd/core", "File:write", "2"), - forms: ["paired"], - }, - { - name: "Dir", - identity: capability("tier-wgac", "Dir", "1"), - forms: ["paired"], - }, - { - name: "File.Delete", - identity: capability("@executablemd/core", "File.Delete", "2"), - forms: ["self-closing"], - }, + 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.Delete", "self-closing", "https://executable.md/components/file/delete", "write"), ]); }); }); @@ -1208,7 +1179,7 @@ describe("Tier WGAC — the standard write table", () => { // 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({ origin: "tier-wgac", key: "Nested", revision: "1" }, "Nested")], + writes: [directoryEntry("Nested")], }); expect(attempt.failure).toBe(undefined); @@ -1216,33 +1187,13 @@ describe("Tier WGAC — the standard write table", () => { const policy = policyOf(admissions(attempt.events)[0]!); expect(policy?.allowed).toEqual([ - { - name: "Json", - identity: capability("@executablemd/core", "Json", "2"), - forms: ["self-closing"], - }, - { - name: "File", - identity: capability("@executablemd/core", "File:write", "2"), - forms: ["paired"], - }, + 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. - { - name: "Dir", - identity: capability("@executablemd/workflow/composition", "Dir", "3"), - forms: ["paired"], - }, - { - name: "File.Delete", - identity: capability("@executablemd/core", "File.Delete", "2"), - forms: ["self-closing"], - }, - { - name: "Nested", - identity: capability("tier-wgac", "Nested", "1"), - forms: ["paired"], - }, + row("Dir", "paired", "https://executable.md/components/dir/ensure", "write"), + row("File.Delete", "self-closing", "https://executable.md/components/file/delete", "write"), + row("Nested", "paired", "https://executable.md/components/dir/ensure", "write"), ]); }); }); @@ -1289,15 +1240,13 @@ describe("Tier WGAC — the standard write table", () => { // Admitted as the exact self-closing identity, and named by the fragment // as the element was written. const admission = admissions(attempt.events)[0]!; - expect(policyOf(admission)?.allowed).toContainEqual({ - name: "File.Delete", - identity: capability("@executablemd/core", "File.Delete", "2"), - forms: ["self-closing"], - }); + expect(policyOf(admission)?.allowed).toContainEqual( + row("File.Delete", "self-closing", "https://executable.md/components/file/delete", "write"), + ); expect(recordedNames(admission)).toEqual([ { name: "File.Delete", - identity: capability("@executablemd/core", "File.Delete", "2"), + identity: "https://executable.md/components/file/delete", form: "self-closing", }, ]); diff --git a/packages/workflow/tests/generated-observations.test.ts b/packages/workflow/tests/generated-observations.test.ts index ea2355f9c..17f101106 100644 --- a/packages/workflow/tests/generated-observations.test.ts +++ b/packages/workflow/tests/generated-observations.test.ts @@ -175,10 +175,10 @@ describe("Tier WGX — the roots a run selects", () => { if (admission?.type !== "yield") { throw new Error("the run recorded no generated-XMD admission"); } - // The version-2 shape: a workflow host states a Workspace basis, and it + // 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: 2, + version: 3, workspace: { roots: [PRIMARY, SECONDARY], selectedRoot: PRIMARY }, }); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index a7f21436b..9d17a8185 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1961,9 +1961,10 @@ run but are absent from the diagnostic trace. | `src/fetch-request.ts` | `prepareFetchRequest()`, `FetchRequest`, `FetchRequestError` — what `` admits before transport (§6.18) | | `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`, `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 form to one exact pinned identity — 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/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/components/component-resolution.ts` | `CanonicalImports`, `ComponentResolution` — the witness a closed execution issues for the definition it produced and verifies where it is invoked | +| `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`, `generatedRefusal()`, `captureGenerated()`, `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 (§5.3) | | `src/invocation.ts` | `withInvocation()`, `Invocation`, `InvocationTeardownError` — the component invocation boundary (§4.4) | | `src/expansion.ts` | `Expansion`, `getExpansion()` — what an executable element knows about its own expansion (§5.6) | | `src/invocation-identity.ts` | `ComponentInvocation`, `IdentityComponent`, `IdentityClaimant`, `ImportSelection`, `installIdentities()`, `issueInvocation()`, `ComponentInvocationError` — what a trusted host declares to an execution, the domains that execution mints for it, and the one-use issuance a claimant answers for (§5.6) | @@ -3295,7 +3296,7 @@ are installed, so a malformed registration is an error in the host rather than a printed error that appears the first time a document writes the name. **What a registration declares.** Beyond its name, origin, function and props -schema, a registration may declare two things the schema cannot describe. +schema, a registration may declare three things the schema cannot describe. `captures` names props the engine does not resolve. They are stripped before expression resolution and excluded from validation, and the component evaluates @@ -3319,6 +3320,23 @@ a different order meant something else. The identity components a host declares to an execution (§5.6) are held to the same rule, whether they are registered by a run or read by inspection. +`generated` names the generated use each authored spelling is offered for: an +absolute identity in the `https://executable.md/` namespace, and whether that +spelling observes or mutates. It belongs here because it is a fact about the +component rather than about a host table that admits it — `` reads and +`…` writes, so one declaration offers two spellings under two +identities, and a continuation is compared against the one the element actually +wrote. Omitting it offers no generated use at all, which is what an ordinary +component offers and takes nothing away from authored use; an empty map and an +omitted spelling say the same thing for that spelling. + +It is validated where the declaration is made, as external data: exactly the two +members, an identity whose origin is that namespace rather than a string +beginning with it, one of the two effects, and a spelling the declaration itself +accepts. Carrying it grants nothing — a table still admits the name, canonical +resolution still decides which definition answers for it, and an attempted +generated use of a spelling with no metadata refuses. + A registration may also carry `description`, `as` and `context` — the same three descriptive fields Markdown frontmatter carries, with the same meaning and the same absence of behavior. Every component a first-party package registers @@ -4371,9 +4389,10 @@ 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 at canonical core's -own origin, key and revision, in its paired spelling only: the question is the -content, so a self-closing one would be a request with nothing in it. +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 @@ -4394,8 +4413,8 @@ the host, an enclosing `` region or the established missing-provider refusal owns how a person is asked — exactly as for an authored ``. Its validated answer is retained by the ordinary `elicit` durable operation, may drive a following write through ordinary bindings, and is restored on replay without -contacting a provider. The `generated_xmd` record keeps the component identity and -the admitted form and nothing about the answer. +contacting a provider. The `generated_xmd` record keeps the identity the +admitted spelling runs under, the spelling itself, and nothing about the answer. An admitted `` is the same element an author writes. Its props, return contract, source rules and sanitized failure sentence are one shared definition @@ -4406,23 +4425,23 @@ 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 at canonical core's own origin, -key and revision, 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, states core's identity for the exact answer -canonical resolution produced, reconciles that identity whole, and seals it -through the execution's own protected-body route. Middleware may observe, -delegate or refuse that import; an answer it replaces carries no identity and is -refused. Admitting 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 states what is behind `` and no other protected name. -Stating an identity is what makes a protected name admissible, so an entry -hand-written at ``'s own canonical identity resolves to an answer -nothing identified and is refused: being shown what a fragment may write is a -different grant from evaluation inside evaluation. +`` 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 +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 +evaluation inside evaluation. **What it renders** is the generated fragment's ordinary output. Evaluate does not inspect component results and does not synthesize an observation or result @@ -4472,7 +4491,8 @@ terminal and unclassified. Records written for a successful result are unchanged **One occurrence is one durable decision.** A continuation restores the admission rather than making it again, and refuses before any effect if the run now offers different text, or states ceilings — effect classes, Workspace roots, -pinned identities, forms or requests — the admission was not granted under. +admitted spellings with their identities and effects, or requests — the admission +was not granted under. #### 5.3.3 Ordinary generated composition @@ -5414,8 +5434,10 @@ the two are separate dispatches. Neither this operation nor the method on the invocation selects an effect. A component whose forms do different things declares them, and canonical invocation-form dispatch (§5.6) enters the one the scan recorded — `` -(§6.13) is dual-form, `` (§6.13.1) is self-closing only, and the -core's protected `` is paired only. A component that reads this +(§6.13) is dual-form, `` (§6.13.1) is self-closing only, and +`` (§6.13.2) is paired only. A declaration that offers a spelling for +generated use states its identity and effect there too, per spelling, so what +one form of a dual-form component grants is never what the other grants. A component that reads this contextual operation has no authored-form guarantee at all: what it receives is whatever the chain answered for that call. @@ -8351,6 +8373,12 @@ member or import-handler answer supplies it. It takes one required prop, ``` +The two spellings are two grants where a generated fragment is concerned, and +this component's own declaration states them: the self-closing one is offered as +a read under `https://executable.md/components/file/read`, and the paired one as +a write under `https://executable.md/components/file/write` (§5.3). A host admits +a spelling; what admitting it means is settled here. + Both forms perform their work through the installed `API.Files` provider, which asks `Permissions` whether the scope reading or writing may do so before its first I/O (§1.2): a refused read or write is an ordinary `permission-denied` @@ -8906,17 +8934,18 @@ 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, as the exact self-closing identity -`@executablemd/core#File.Delete` (workflow-workspace-spec §8.4). A write-enabled -fragment therefore removes one file by writing this component, and it is this -component that runs: an admitted deletion crosses `API.Files` into the run's +profile's write table (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 +therefore removes one file by writing this component, and it is this component +that runs: an admitted deletion crosses `API.Files` into the run's transaction-bound provider and is retained by the ordinary `workspace_file` effect described above. The evaluator collects nothing from it and invents no receipt, so a fragment that only deletes renders nothing unless it authors output around the deletion. The paired spelling is refused in whole-fragment preflight, before the -fragment's first effect, because the identity is admitted for the self-closing -form alone. +fragment's first effect, because it is offered no generated use at all. --- @@ -8932,6 +8961,11 @@ A document that writes several files in one place names that place once. ``` +Its paired spelling is offered to a generated fragment as a write, under +`https://executable.md/components/dir/ensure` (§5.3). Whether a run admits the +name at all is the host's — a workflow run has a `` and an ordinary run has +none — and what admitting it grants is this component's. + It does two things, in this order. First the directory named by `path` is made to exist: created recursively when it is missing, used as it stands when it is already a directory, and refused when something that is not a directory is @@ -12285,11 +12319,11 @@ through the captured capability because there is no other way to reach it. | FE11 | Completed Plan work and completed fragment effects replay without repetition. | | FE12 | Hostile or malformed durable records fail closed before effects. | | FE13 | Declared-component private closures and producer-private capabilities remain unavailable. | -| FE14 | Middleware answer A is retained by stable provider identity; continuation with B refuses before A or B runs; unchanged A resumes. | -| FE15 | Provider registrations disappear at teardown; losing or cancelled resolution claims cannot later execute. | +| FE14 | Middleware answer A is retained by the publication its provider made on that exact object; continuation with B, offered under another identity by its own declaration, refuses before A or B runs; unchanged A resumes. | +| FE15 | Provider registrations disappear at teardown; a losing or cancelled resolution's publication cannot later execute. | | FE16 | Repeated Evaluate occurrences cannot consume one another's records. | | FE17 | Actual cancellation stops active work, waits for cleanup, and records no false success. | -| FE18 | Existing untagged #369 records remain readable; new records use the closed version-2 shape. | +| FE18 | One closed current record shape, marked as itself; a record an earlier build wrote is refused as unreadable history before the admission it would have restored performs anything. | | FE19 | Canonical `text` produces identical output across fresh workflow executions without warnings; `source` and `program` refuse before fragment effects under every profile. | | FE20 | Exact trusted Fetch limits are retained and narrowed; missing or changed limits refuse before requests. | | FE21 | Ordinary and workflow hosts exercise the same fragment evaluator rather than separate semantic implementations. | @@ -13073,10 +13107,11 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §§8.4 and 9. | GX18c–GX18d | Controls | An empty and a rendered-only fragment preserve output across partial replay and invent no nested durable effect | | GX22, GX22b | Progression | The run's own root addition with the added root current, and standing on another root it already retained, each resume and reach the generated component | | GX22c–GX22d | Root loss | Losing a non-selected admission root and losing the admission's selected root each refuse with the fixed diagnostic before the component runs, the live component count unchanged | -| GX14b–GX14c | One ceiling per identity | Two admitted identities whose four terms differ only in where a space falls each perform exactly their own admitted request, and neither entry's limit admits the other's: the run's ceiling table is keyed by the terms encoded rather than joined, because nothing makes a separator illegal in an origin or a key | -| FE18/GX21z | Version-1 reconciliation is stated, not inferred | The standard core admission a released build wrote — `@executablemd/core#File:read` under the untagged policy shape, with no revision in it — resumes because `pinnedFileRead()` states that exact string as the version-1 identity it succeeds; an arbitrary host string a released build retained verbatim resumes the same way against its entry's stated alias. An entry that states no alias refuses the record that named it, with the admitted read never reaching the provider; a string no entry lists refuses; and a string never reconciles to a component answer, whose arm did not exist when version-1 records were written | -| FE18/GX21x, FE18/GX21y | The closed version-2 shape | A literal version-1 refusal replays as the refusal it recorded. A version-2 identity written as a string, with no kind, an unknown kind, a missing or extra member, a non-string member, as an array or as null each refuse, as do a tagged result holding an untagged policy, a mixed or unknown version, a duplicate or empty class or form list, and a malformed request field. Each version has a positive control resuming on the unmodified record, so no row passes because a forged record never resumes at all | -| 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; a second identity for that name — within one table, across the two, or under another origin — refuses at capture naming both spellings rather than letting assembly order decide; 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` | +| 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` | +| GOM1–GOM3 | 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. In `generated-owner-metadata.test.ts` | ### Tier WGAC — Generated `` effects and the `` boundary @@ -13084,8 +13119,8 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §8.4. | # | Test | Verify | |---|------|--------| -| WGAC1 | The read identity | A self-closing `` reads through the ordinary Files provider, and its pinned identity is not the unconstrained one | -| WGAC13 | The deletion identity | The pinned `` entry is `@executablemd/core#File.Delete`, admitted for the self-closing form alone, and carries the exact definition object core's registry holds rather than one shaped like it | +| WGAC1 | The read entry | A self-closing `` reads through the ordinary Files provider, and the entry admits the reading spelling alone — the writing one is a different grant under a different identity on the same definition | +| WGAC13 | The deletion entry | The pinned `` entry is admitted for the self-closing form alone, under `https://executable.md/components/file/delete` as a write, and carries the exact definition object core holds rather than one shaped like it | | WGAC2 | Preflight | A paired `` — empty or not — and an unadmitted component anywhere in a mixed fragment each perform zero reads and zero writes, and nothing of the source reaches the record | | WGAC3 | Closed schema | `` refuses content, refuses every prop but `source` and `allow`, and refuses a missing `source` | | WGAC4 | Host-owned roots | The stated ceiling is the run's retained roots and the root it is on, following the run as it moves, in a deterministic order; generated source cannot reach the component even while it is live | @@ -13711,12 +13746,12 @@ Defined in §5.6, with the selection rule in §5.3. | CIV20 | Reading costs nothing | Reading the form, twice, leaves the durable identity unspent — the claim after it still succeeds | | CIV21 | Owner-kept | A repository component that imports nothing reads the canonical form from the object it received, for both authored forms; the fact appears on no definition a handler holding one can read | | CIV22 | Only the engine's own invocation enters a body | The dispatcher enters a form-specific body for a genuine, live, selected invocation of the form it answers, and refuses every other call before the body: a structural look-alike implementing `hasContent()`, a descriptor-for-descriptor clone, an object built on the prototype, an issuance canonical resolution selected nothing for, one selected for another dispatcher, and a closed one. Each look-alike's own `hasContent()` answers plausibly, which is why none of them is asked | -| CIV23 | The identity a provider states for its answer | A request states a claim on the exact object its handler returns, under the name and provider origin canonical execution fixed when it minted that request. A different object, a copy, an object edited after the claim, a competing provider installation, another key and another revision each identify nothing, and the first statement stands after every one of them. The provider states only key and revision, and a partial identity is refused rather than half-recorded. Backs FE14 | -| CIV24 | Identification is one atomic answer | The claim and core's own claim-time copy of what was claimed come back from one call, so nothing downstream reads the chain's object again: an answer whose member alternates between the claimed value and a substitution is sealed as the claimed one, with the plant proven live by the next read. At execution scale, an answer whose props schema alternates validates the fragment against the claimed contract. Backs FE14 | -| CIV25 | A request belongs to one resolution occurrence | Provider installation and the right to answer one occurrence are separate. An installation receives a registrar and installs import middleware once; every middleware invocation receives a fresh request with readonly name and position, plus `next`. The request captures the exact resolution-window object, provider-installation token, origin and name, and `claim` accepts only the answer plus key and revision. Its handler closes the request synchronously in `finally` on return, failure or cancellation, while an outer request stays live across `yield* next()` and may claim its replacement after the delegated handler returns. The enclosing resolution closes in its existing `finally`. A claim succeeds only during active execution, through an open request whose captured window is the exact current object and whose fixed name matches it, with no different statement by that provider in the window. Identification takes the expected window explicitly and accepts only that exact window and name. Thus a stale request from resolution N refuses during N+1 of the same name, a stale `Open` request cannot retag while `Other` is live, and a losing inner request refuses after its handler returns while the still-live outer request may claim after delegation. One installation answers several names and repeated same-name resolutions through distinct requests; unidentified replacements remain ordinary valid middleware answers outside fragment evaluation. Two owners live at once each answer only for what they recorded, and tearing one down leaves the other working. No Context, shared symbol, public brand or module-global registry participates. Backs FE15 | +| CIV23 | What a provider publishes for its answer | A request publishes the exact object its handler returns, under the name and provider origin canonical execution fixed when it minted that request. A different object, a copy, an object edited after publication, a competing provider installation and a second publication of a different implementation each identify nothing, and the first publication stands after every one of them. The provider publishes the definition and nothing else: which identity and effect each of its spellings runs as is on that definition, and metadata this engine cannot read — a relative or look-alike identity, an unknown effect, an unoffered spelling — is refused rather than half-recorded. An answer offering no generated use at all is published and read back as the ordinary answer it is. Backs FE14 | +| CIV24 | Identification is one atomic answer | Core's own publication-time copy and the publishing installation's origin come back from one call, so nothing downstream reads the chain's object again: an answer whose member alternates between the published value and a substitution is sealed as the published one, with the plant proven live by the next read. At execution scale, an answer whose props schema alternates validates the fragment against the published contract. Backs FE14 | +| CIV25 | A request belongs to one resolution occurrence | Provider installation and the right to answer one occurrence are separate. An installation receives a registrar and installs import middleware once; every middleware invocation receives a fresh request with readonly name and position, plus `next`. The request captures the exact resolution-window object, provider-installation token, origin and name, and `answer` accepts the definition alone. Its handler closes the request synchronously in `finally` on return, failure or cancellation, while an outer request stays live across `yield* next()` and may claim its replacement after the delegated handler returns. The enclosing resolution closes in its existing `finally`. A publication succeeds only during active execution, through an open request whose captured window is the exact current object and whose fixed name matches it, with no different implementation published by that provider in the window. Identification takes the expected window explicitly and accepts only that exact window and name. Thus a stale request from resolution N refuses during N+1 of the same name, a stale `Open` request cannot publish while `Other` is live, and a losing inner request refuses after its handler returns while the still-live outer request may publish after delegation. One installation answers several names and repeated same-name resolutions through distinct requests; unpublished replacements remain ordinary valid middleware answers outside fragment evaluation. Two owners live at once each answer only for what they recorded, and tearing one down leaves the other working. No Context, shared symbol, public brand or module-global registry participates. Backs FE15 | | CIV26 | The terminal is the association | Import middleware that delegates the same name once from inside `scoped()` keeps the invocation's identity, and names the same one an undelegated import does. Two nested authored imports each own their terminal, so both are identified without a stack. Delegating twice, or delegating another name, reaches this import's own terminal and settles it to nothing | | CIV27 | Two imports live at once | Two `` children are each suspended inside their own import before either resolves. Each dispatch selects into the frame its terminal carries, so both invocations are identified exactly once, their identities differ, and they are the identities the same two sites name when nothing holds their imports — in either release order | -| CIV28 | A cancelled import closes only its own frame | With two imports live, one is released and claims while the other is still suspended and is cancelled by a failing sibling. The survivor is refused nothing and names exactly what that site names when nothing is torn down beside it; the cancelled branch claims nothing, and nothing it left behind lends or withholds an identity afterwards | +| CIV28 | A cancelled import closes only its own frame | With two imports live, one is released and publishes while the other is still suspended and is cancelled by a failing sibling. The survivor is refused nothing and names exactly what that site names when nothing is torn down beside it; the cancelled branch publishes nothing, and nothing it left behind lends or withholds an identity afterwards | ### Tier NEX — Nested document executions (`specs/testing-spec.md`) diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 36db1259a..3ed901381 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -1111,15 +1111,14 @@ 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 the versioned pinned identity -`@executablemd/workflow/composition/dir-v2#Dir` in the standard generated-XMD -write table (§8.4), built from this same implementation and schema. Selecting +Its paired form is also what the standard generated-XMD write table admits +(§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 `allow={["write"]}` intentionally authorizes its persistent recursive directory -creation. The former `@executablemd/workflow/composition#Dir` identity never -authorizes creation, so a continuation retaining it refuses before generated -execution. Being registered grants the current identity nothing: a generated -fragment reaches the pinned identity and never the registration, and a -repository component named `Dir` satisfies neither. +creation. Being registered grants that identity nothing: a generated fragment +reaches the admitted entry and never the registration, and a repository +component named `Dir` satisfies neither. Everything the lexical directory covers reads and writes through it, including `` (§10.1), which removes one file from the Workspace the same @@ -1856,16 +1855,18 @@ 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 the candidate is parsed and before any `generated_xmd` record exists. -`allow` narrows; it never grants. It cannot create a pinned identity, add a +`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. #### 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 pinned -component identities, the exact requests each admitted read may perform, and the -classes this fragment draws on. Before the first effect, the evaluator: +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: - parses the complete source; - walks every element and every element's content; @@ -1934,22 +1935,21 @@ 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. -Non-root terms must match exactly in canonical order: selected classes, pinned -identities and forms, 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 durable policy identity. - -`File` is admitted as two identities, never as one. Core's pinned **read** -identity is the self-closing form and core's pinned **write** identity is the -paired form, including the empty paired one, which truncates: `` reads -when it has no content and writes when it has some, so the two spellings are two -identities and the constraint travels with the pinned one rather than being -checked inside the component. A paired `` under a `read`-only selection, -and a self-closing `` under a `write`-only one, are each refused in -preflight, so the fragment performs no read and no write at all. The -unconstrained core `File` definition is never admitted, and a repository -component named `File` is neither pinned identity. +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. + +`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 +paired one, which truncates — as a **write**: `` reads when it has no +content and writes when it has some, so the two spellings carry two identities +and the constraint belongs to the component rather than being checked inside its +body. A paired `` under a `read`-only selection, and a self-closing +`` under a `write`-only one, are each refused in preflight, so the +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 @@ -1957,10 +1957,10 @@ definitions are a malformed host table, refused before anything is retained rather than settled by whichever entry the host listed first. **The admitted form also holds at invocation.** Preflight selects the exact -name and form before the first effect; canonical dispatch then holds the body -to that selection before any provider is reached. An admitted `File:read` -cannot become a write, an admitted `File:write` cannot become a read, and only -self-closing `File.Delete` is admitted. The [generated-XMD boundary](../architecture.md#agent-permissions-and-generated-xmd) +name and spelling before the first effect; canonical dispatch then holds the +body to that selection before any provider is reached. An admitted reading +`` cannot become a write, an admitted writing `…` cannot +become a read, and only self-closing `` is admitted. The [generated-XMD boundary](../architecture.md#agent-permissions-and-generated-xmd) owns the engine-authenticated form check; replaceable `Component.hasContent()` answers and caller-created invocation objects cannot decide it. @@ -2086,19 +2086,12 @@ 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 holds exactly three identities, in the order -it states them: core's paired `File:write`, the paired versioned -`@executablemd/workflow/composition/dir-v2#Dir` — a host-stated identity whose -body canonical core supplies, so the admitted grant and the authored component -are one implementation — and core's self-closing `File.Delete`. A host's own -extensions come after them, and the profile supplies no external-write and no -execution table. - -That `` identity names *retained history*, not the package the component -lives in. Every journal a released build wrote holds the string above and a -continuation compares the table position by position, so the identity stays as -written even though canonical core now owns the component -(`specs/executable-mdx-spec.md` §6.13.2). +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 @@ -2118,11 +2111,10 @@ the evaluator adds none: it owns admission, and the component's own contract owns atomicity, failure and cancellation. Selecting `allow={["write"]}` therefore intentionally authorizes persistent, -recursive directory creation by the current pinned ``. The former -`@executablemd/workflow/composition#Dir` identity described contextual placement -without creation and is not interchangeable with this permission. A continuation -whose retained table contains that former identity refuses before any generated -component executes; the write ceiling is not silently broadened. +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. Because the whole fragment is decided inside the admission before its first effect, a fragment mixing an admitted write with anything unadmitted — a later @@ -3504,7 +3496,7 @@ fetch operation requires its own language and durability contract. | workflow Agent session retention | built by #302; [§8.5](#85-agent-session-continuity) owns logical session identity, compatibility, first-turn publication and reattachment/refusal behavior | | generated-XMD admission | built by #369 through `@executablemd/core/host`; the workflow policy wrapper is internal. [§8.4](#84-generated-xmd) owns the workflow tables and selected classes; [architecture](../architecture.md#agent-permissions-and-generated-xmd) owns admission and continuation mechanics | | `` and the authored loop | built by #302 and #369, canonical since #713; [§8.4](#the-authored-loop-and-where-it-is-written) owns text/paired inputs, the closed `text`/`allow` schema, rendered-string output and authored iteration/approval. The host supplies a captured ceiling; core supplies the protected component | -| generated-XMD mutation-proposal admission | built by #369 and #567, amended by #643; [§8.4](#mutation-proposal-admission) owns the ordered write profile, retained Dir compatibility, ordinary effect transactions and authored approval. Evaluate returns rendered text, with no result envelope or mutation receipt | +| generated-XMD mutation-proposal admission | built by #369 and #567, amended by #643; [§8.4](#mutation-proposal-admission) owns the ordered write profile, the identity each admitted spelling runs under, ordinary effect transactions and authored approval. Evaluate returns rendered text, with no result envelope or mutation receipt | | Deno-local DOFS persistence | POC proven by #349 / PR #350 | | scoped Deno Worker Shell | containment proven by #351 / PR #353 and transactions by #357 / PR #362; production integration unbuilt | | Worker JavaScript | deferred | From 1d67b5ef1a51bc7d94049f8f308a76eb792a2ed3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 14 Oct 2026 20:39:11 -0400 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=94=92=20Read=20a=20declaration's=20o?= =?UTF-8?q?ffered=20spellings=20once,=20wherever=20it=20is=20admitted=20(#?= =?UTF-8?q?181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Planner's probe found three gaps in the owner-metadata boundary. A directly pinned entry reached the generated table unread: a definition accepting only its paired form still ran ``, and an identity that is not a URL was appended as an admission before the reader of that fresh record rejected it. The declaration is read now in `selectedEntries`, for observations and mutations alike, so a malformed one refuses before any record exists and before any body runs. The captured profile reads a sealed definition the same way. The accepted-forms contract moves to `invocation-forms.ts`, which the metadata validator can read without going through the component graph. Only a genuinely omitted `forms` means both spellings, so `forms: "paired"` and `["self-closing", "unknown"]` are refused rather than treated as absent or filtered into a declaration that looks canonical. Registration validated the declarer's object and then copied it again, so an identity accessor answering a valid URL for the check and `not-a-url` for the copy installed the value the check never saw. `admitGeneratedForms()` now reads each member exactly once and answers with the copy, and registration, every identity-component arm and core's own owners retain that same value; a failed capture leaves the whole batch uninstalled. U4-7's positive case is covered: a provider module loaded from a temporary directory, holding no core module at all, publishes its own definition through the handed `answer(definition)`, the fragment runs that body, and the admission retains the identity the loaded module's own declaration states. --- packages/core/src/component-answers.ts | 39 ++-- .../core/src/components/declared-markdown.ts | 2 +- .../core/src/components/fixed-filesystem.ts | 14 +- .../core/src/components/generated-metadata.ts | 169 ++++++++++-------- packages/core/src/components/registration.ts | 37 +++- packages/core/src/components/registry.ts | 12 +- packages/core/src/evaluation-profile.ts | 12 +- packages/core/src/execution-declarations.ts | 4 +- packages/core/src/generated-xmd.ts | 31 +++- packages/core/src/invocation-forms.ts | 77 ++++++++ packages/core/src/invocation-identity.ts | 90 ++++------ packages/core/tests/answer-identity.test.ts | 19 ++ .../core/tests/evaluate-component.test.ts | 30 ++++ .../tests/generated-owner-metadata.test.ts | 97 +++++++++- packages/core/tests/generated-xmd.test.ts | 101 +++++++++++ .../core/tests/syntax-loaded-copy.test.ts | 150 +++++++++++++++- specs/executable-mdx-spec.md | 11 +- 17 files changed, 720 insertions(+), 175 deletions(-) create mode 100644 packages/core/src/invocation-forms.ts diff --git a/packages/core/src/component-answers.ts b/packages/core/src/component-answers.ts index 2ee95e831..b1a7de870 100644 --- a/packages/core/src/component-answers.ts +++ b/packages/core/src/component-answers.ts @@ -3,37 +3,44 @@ * * A `component-answer` entry names something the ordinary import chain * resolves rather than an operation core supplies a body for. The host does - * not hand over the implementation — it states which name, and which - * structural identity a provider must have stated for that name. This module - * is the seam a provider uses to state that identity. + * not hand over the implementation — it states which name, and which spellings + * of it the entry admits. This module is the seam a provider publishes the + * definition through, and what each of that definition's spellings runs as is + * declared on the definition itself + * (`components/generated-metadata.ts`). * * ## Being asked and answering are two separate rights * * A provider installs through a *registrar*, and receives no handle it can keep - * and state an answer with. The registrar is the right to be asked; each + * and publish an answer with. The registrar is the right to be asked; each * handler invocation then receives its own {@link ComponentAnswerRequest}, * which is the right to answer *that* asking and nothing else. * * An installation is reusable, but cannot identify which invocation is * speaking. Each request captures its resolution by object identity and closes * when its handler returns, throws or is cancelled, so a finished or losing - * handler cannot state an answer to an import it is no longer deciding. + * handler cannot publish an answer to an import it is no longer deciding. * - * So `claim` takes no name. The name is fixed when the request is minted, from - * what the chain asked, and a handler cannot state an answer for a component it - * was never asked about. + * So `answer` takes the definition and nothing else. The name is fixed when the + * request is minted, from what the chain asked, so a handler cannot answer for + * a component it was never asked about; and the identity and effect of each + * spelling are the definition's own, so a provider cannot describe an + * implementation it did not supply. The installation origin it carries is + * provenance a reader and a documentation symbol are shown, and no part of what + * the implementation is. * * All of it is ordinary closures. There is no shared symbol, module registry or * context name behind any of these objects, which is what lets a separately * loaded copy of core hold one without any of those being a way in. * - * ## Identifying is not permission + * ## Publishing is not permission * - * An identity statement permits nothing. A fragment still needs the host entry, - * an exact identity match, the right `allow` class, whole-fragment preflight - * and canonical generated import — all of them independently. Middleware that - * answers an import without stating an identity is an ordinary replacement - * and stays exactly as valid as it was; it cannot enter an admitted fragment. + * A publication permits nothing. A fragment still needs the host entry, the + * spelling the definition's own declaration offers, the right `allow` class, + * whole-fragment preflight and canonical generated import — all of them + * independently. Middleware that answers an import without publishing it is an + * ordinary replacement and stays exactly as valid as it was; it cannot enter an + * admitted fragment. */ import type { Operation } from "effection"; @@ -85,8 +92,8 @@ export interface ComponentAnswerRegistrar { /** * One provider's installation, run during capture and before ordinary installs. * - * `origin` is the provider's name for itself and becomes the origin on every - * identity its requests state. `install` registers whatever import middleware + * `origin` is the provider's name for itself, and the provenance every answer + * its requests publish carries. `install` registers whatever import middleware * answers for its names. */ export interface ComponentAnswerInstallation { diff --git a/packages/core/src/components/declared-markdown.ts b/packages/core/src/components/declared-markdown.ts index 3a2318f7c..1e1c3b8c6 100644 --- a/packages/core/src/components/declared-markdown.ts +++ b/packages/core/src/components/declared-markdown.ts @@ -43,7 +43,7 @@ import type { Operation } from "effection"; import { canonicalFingerprint } from "../canonical.ts"; import { parseMarkdownDefinition } from "../definition.ts"; -import { formsRefusal } from "../invocation-identity.ts"; +import { formsRefusal } from "../invocation-forms.ts"; import type { IdentityComponent } from "../invocation-identity.ts"; import { RESERVED_STRUCTURAL } from "../structural.ts"; import { PROTECTED_COMPONENT_NAMES, protectedNameRefusal } from "./protected.ts"; diff --git a/packages/core/src/components/fixed-filesystem.ts b/packages/core/src/components/fixed-filesystem.ts index cada3db95..7bdd4042a 100644 --- a/packages/core/src/components/fixed-filesystem.ts +++ b/packages/core/src/components/fixed-filesystem.ts @@ -39,7 +39,7 @@ import { form as dirForm, props as dirProps } from "./Dir.ts"; import { form as fileForm, props as fileProps } from "./File.ts"; import { form as fileDeleteForm, props as fileDeleteProps } from "./FileDelete.ts"; import { declaredForms, formDispatcher } from "../invocation-identity.ts"; -import { captureGenerated } from "./generated-metadata.ts"; +import { admitGeneratedForms } from "./generated-metadata.ts"; import type { GeneratedForms } from "./generated-metadata.ts"; import { parseJsonObject } from "../json.ts"; import { documented } from "./documentation.ts"; @@ -79,12 +79,20 @@ function fixed( documentation: FirstPartyDocumentation, generated: GeneratedForms, ): FunctionComponentDefinition { + const forms = Object.freeze(declaredForms(declaration)); + // Core's own table, read through the same validator a host's declaration is: + // these three definitions are shared by every execution in the process, so + // what they offer is checked and frozen where it is declared. + const admitted = admitGeneratedForms(generated, forms); + if (!admitted.ok) { + throw new Error(`canonical core's <${name}> ${admitted.error.message}`); + } return Object.freeze({ kind: "function", name, props: freezeThrough(props), - forms: Object.freeze(declaredForms(declaration)), - generated: captureGenerated(generated), + forms, + generated: admitted.value, ...documented(documentation), fn: formDispatcher(declaration), }); diff --git a/packages/core/src/components/generated-metadata.ts b/packages/core/src/components/generated-metadata.ts index d3e140502..0364757a4 100644 --- a/packages/core/src/components/generated-metadata.ts +++ b/packages/core/src/components/generated-metadata.ts @@ -14,19 +14,31 @@ * case: metadata is what makes a form offerable, and offering none takes * nothing away from authored use. * - * ## Validated, not trusted + * ## Read once, validated and retained as one value * - * A declaration crosses a package boundary, so this reads it the way durable - * input is read: exactly the two members, an identity in the accepted HTTPS - * namespace, one of the two effects, and a form the declaration itself accepts. - * The identity is stored and compared as the supplied string — nothing fetches - * it, normalizes it into an alias, or infers it from the implementation. + * A declaration crosses a package boundary, so the metadata is read the way + * durable input is read: exactly the two members, an identity in the accepted + * HTTPS namespace, one of the two effects, and a spelling the declaration + * itself accepts. The identity is stored and compared as the supplied string — + * nothing fetches it, normalizes it into an alias, or infers it from the + * implementation. + * + * One pass does all of it, and what it answers with is the copy. Validating the + * declarer's object and then copying it again would be two readings of a value + * the declarer controls: an accessor answering a valid URL for the check and + * another for the copy would have installed what the check never saw. So + * `admitGeneratedForms()` reads each member exactly once, into data of its own, + * and the caller retains that same data. * * It imports no registry and no protected table. Validating plain metadata * through the component graph would reopen the initialization cycle that graph * already exposes, and nothing here needs to know which components exist. */ +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + +import { acceptedForms, formsRefusal } from "../invocation-forms.ts"; import type { InvocationForm } from "../invocation-identity.ts"; /** Whether a generated form observes or mutates. */ @@ -59,46 +71,53 @@ function generatedEffect(value: unknown): GeneratedEffect | undefined { } /** - * Why this metadata is unusable, or nothing when it is usable. + * This declaration's generated metadata, read once into owned frozen data — or + * the reason it is unusable. * - * A sentence rather than a thrown error, like `formsRefusal()`: a registration, - * a factory declaration and a resolved provider definition each refuse in their - * own error family and with their own diagnostic context, and a shared throw - * here would take that context away from all three. - * - * `accepted` is the forms the declaration itself accepts — `undefined` keeping - * its ordinary meaning of both — because offering generated use of a form the + * `accepted` is the forms the declaration itself accepts, `undefined` keeping + * its ordinary meaning of both, because offering generated use of a form the * component refuses to be written in is offering nothing that can run. + * + * Frozen through, because a shared definition is read by every execution in the + * process: a nested record left mutable would be what the next execution took + * its own first copy of, with the change already in it. */ -export function generatedRefusal( +export function admitGeneratedForms( generated: unknown, accepted: readonly InvocationForm[] | undefined, -): string | undefined { - if (generated === undefined) { - return undefined; - } +): Result { if (typeof generated !== "object" || generated === null || Array.isArray(generated)) { - return "states generated forms that are not a map of authored forms"; + return Err(new Error("states generated forms that are not a map of authored forms")); } const offerable = accepted === undefined ? FORMS : accepted; + const captured: Partial> = {}; for (const key of Reflect.ownKeys(generated)) { const form = authoredForm(key); if (form === undefined) { - return `states generated metadata for "${String(key)}", which is not an authored form`; + return Err( + new Error(`states generated metadata for "${String(key)}", which is not an authored form`), + ); } if (!offerable.includes(form)) { - return `offers generated use of its ${form} form, which it does not accept`; + return Err(new Error(`offers generated use of its ${form} form, which it does not accept`)); } - const refusal = formRefusal(Reflect.get(generated, form), form); - if (refusal !== undefined) { - return refusal; + // One read of each member, into data this module owns. Everything below — + // the checks and the retained copy alike — reads that data and never the + // declarer's object again. + const offered = read(Reflect.get(generated, form), form); + if (!offered.ok) { + return offered; } + captured[form] = offered.value; } - return undefined; + return Ok(Object.freeze(captured)); } -function formRefusal(value: unknown, form: InvocationForm): string | undefined { - const malformed = `states a ${form} generated form that is not an identity and an effect`; +/** One offered form, read once and checked. */ +function read(value: unknown, form: InvocationForm): Result { + const malformed = Err( + new Error(`states a ${form} generated form that is not an identity and an effect`), + ); if (typeof value !== "object" || value === null || Array.isArray(value)) { return malformed; } @@ -106,17 +125,39 @@ function formRefusal(value: unknown, form: InvocationForm): string | undefined { if (members.length !== 2 || !members.includes("identity") || !members.includes("effect")) { return malformed; } - if (generatedEffect(Reflect.get(value, "effect")) === undefined) { - return `offers its ${form} form under an effect this engine does not have`; - } const identity = Reflect.get(value, "identity"); + const effect = generatedEffect(Reflect.get(value, "effect")); + if (effect === undefined) { + return Err(new Error(`offers its ${form} form under an effect this engine does not have`)); + } if (typeof identity !== "string" || !isGeneratedIdentity(identity)) { - return ( - `offers its ${form} form under "${String(identity)}", which is not an absolute URL in ` + - `${GENERATED_IDENTITY_ORIGIN}` + return Err( + new Error( + `offers its ${form} form under "${String(identity)}", which is not an absolute URL in ` + + GENERATED_IDENTITY_ORIGIN, + ), ); } - return undefined; + return Ok(Object.freeze({ identity, effect })); +} + +/** + * 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. + */ +export function generatedRefusal( + generated: unknown, + accepted: readonly InvocationForm[] | undefined, +): string | undefined { + if (generated === undefined) { + return undefined; + } + const admitted = admitGeneratedForms(generated, accepted); + return admitted.ok ? undefined : admitted.error.message; } /** @@ -135,25 +176,6 @@ export function isGeneratedIdentity(identity: string): boolean { } } -/** - * This metadata as the engine keeps it: a frozen copy, down to each form. - * - * Copied because the declaration's object is the declarer's, and frozen through - * because a shared definition is read by every execution in the process — a - * nested record left mutable would be what the next execution took its own - * first copy of, with the change already in it. - */ -export function captureGenerated(generated: GeneratedForms): GeneratedForms { - const captured: Partial> = {}; - for (const form of FORMS) { - const offered = generated[form]; - if (offered !== undefined) { - captured[form] = Object.freeze({ identity: offered.identity, effect: offered.effect }); - } - } - return Object.freeze(captured); -} - /** * The identity and effect one authored form is offered under, when it is. * @@ -170,34 +192,27 @@ export function offeredForm( /** * Why a definition's own generated metadata is unusable, or nothing. * - * For a definition that never passed registration — a provider's published - * answer — where both members have to be read off the object defensively - * rather than destructured from a declared shape. The accepted forms are read - * the same way, because a definition stating forms this engine does not have - * accepts none of them. + * The check every *live* admission makes, wherever a definition reaches a + * generated table: a provider's published answer, a host's directly pinned + * entry, and a captured profile entry are all definitions nothing validated on + * the way in, so each is read here before anything is admitted or retained. + * + * A definition offering generated use is held to its own stated forms, through + * the same accepted-form contract a registration is. That value is read rather + * than trusted too: only genuine omission means both forms, so a malformed + * `forms` is refused instead of being filtered into a declaration that looks + * canonical. A definition offering no generated use is left alone, because it + * is asking for nothing that identity or effect decides. */ export function definitionGeneratedRefusal(definition: object): string | undefined { const generated = Reflect.get(definition, "generated"); if (generated === undefined) { return undefined; } - return generatedRefusal(generated, acceptedForms(Reflect.get(definition, "forms"))); -} - -/** - * The authored forms this value states, or `undefined` for a value that states - * none — which is every form, exactly as an omitted declaration means both. - */ -function acceptedForms(value: unknown): readonly InvocationForm[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const accepted: InvocationForm[] = []; - for (const member of value) { - const form = authoredForm(member); - if (form !== undefined) { - accepted.push(form); - } + const stated = Reflect.get(definition, "forms"); + const badForms = formsRefusal(stated); + if (badForms !== undefined) { + return badForms; } - return accepted; + return generatedRefusal(generated, acceptedForms(stated)); } diff --git a/packages/core/src/components/registration.ts b/packages/core/src/components/registration.ts index 50fc4bc7d..481c22ef2 100644 --- a/packages/core/src/components/registration.ts +++ b/packages/core/src/components/registration.ts @@ -18,8 +18,8 @@ import { Component } from "../component-api.ts"; import { updateOwn } from "../scope-local.ts"; import { RESERVED_STRUCTURAL } from "../structural.ts"; import { PROTECTED_COMPONENT_NAMES, protectedNameRefusal } from "./protected.ts"; -import { formsRefusal } from "../invocation-identity.ts"; -import { captureGenerated, generatedRefusal } from "./generated-metadata.ts"; +import { formsRefusal } from "../invocation-forms.ts"; +import { admitGeneratedForms } from "./generated-metadata.ts"; import type { GeneratedForms } from "./generated-metadata.ts"; import { documentationOf } from "./documentation.ts"; import type { ComponentDocumentation } from "./documentation.ts"; @@ -238,6 +238,19 @@ function assertUsableCaptures( } } +/** What this scope accepted of a declaration, as its own data. */ +export interface AdmittedDeclaration { + /** + * The generated metadata this declaration offers, read once and validated. + * + * The value a caller retains. Reading the declarer's object again to build + * the definition would be a second reading of a value the declarer controls, + * and an accessor answering one identity for the check and another for the + * copy would install what the check never saw. + */ + readonly generated?: GeneratedForms; +} + /** * What a component declaration has to be for this scope to accept it, whoever * is declaring. @@ -253,7 +266,9 @@ function assertUsableCaptures( * Every check is on the declaration alone. Nothing here calls an * implementation, so it is usable where there is no implementation yet. */ -export function* admitDeclaration(declaration: ComponentDeclaration): Operation { +export function* admitDeclaration( + declaration: ComponentDeclaration, +): Operation { const { name, origin, props, returns, captures, forms, generated } = declaration; assertUsableName(name); if (origin.length === 0) { @@ -270,10 +285,14 @@ export function* admitDeclaration(declaration: ComponentDeclaration): Operation< if (badForms !== undefined) { throw new ComponentRegistrationError(`the declaration for "${name}" ${badForms}`); } - const badGenerated = generatedRefusal(generated, forms); - if (badGenerated !== undefined) { - throw new ComponentRegistrationError(`the declaration for "${name}" ${badGenerated}`); + if (generated === undefined) { + return {}; + } + const admitted = admitGeneratedForms(generated, forms); + if (!admitted.ok) { + throw new ComponentRegistrationError(`the declaration for "${name}" ${admitted.error.message}`); } + return { generated: admitted.value }; } /** Everything a declaration says about itself, apart from what implements it. */ @@ -298,8 +317,8 @@ export function* registerComponents( const additions = new Map>>(); for (const registration of registrations) { - const { name, origin, fn, props, returns, captures, forms, generated } = registration; - yield* admitDeclaration(registration); + const { name, origin, fn, props, returns, captures, forms } = registration; + const admitted = yield* admitDeclaration(registration); const kind = kindOf(registration); const already = additions.get(name)?.[kind]; @@ -315,7 +334,7 @@ export function* registerComponents( ...(returns ? { returns } : {}), ...(captures && captures.length > 0 ? { captures } : {}), ...(forms ? { forms } : {}), - ...(generated === undefined ? {} : { generated: captureGenerated(generated) }), + ...(admitted.generated === undefined ? {} : { generated: admitted.generated }), ...documentationOf(registration), }; batch.set(name, { ...batch.get(name), [kind]: { definition, origin } }); diff --git a/packages/core/src/components/registry.ts b/packages/core/src/components/registry.ts index e7f479953..415440402 100644 --- a/packages/core/src/components/registry.ts +++ b/packages/core/src/components/registry.ts @@ -35,7 +35,7 @@ import Test, { props as testProps } from "./Test.ts"; import { parseJsonObject } from "../json.ts"; import { declaredForms, formDispatcher } from "../invocation-identity.ts"; import { documented } from "./documentation.ts"; -import { captureGenerated } from "./generated-metadata.ts"; +import { admitGeneratedForms } from "./generated-metadata.ts"; import type { GeneratedForms } from "./generated-metadata.ts"; import type { FirstPartyDocumentation } from "./documentation.ts"; import type { @@ -105,6 +105,14 @@ function core( const fn = typeof implementation === "function" ? implementation : formDispatcher(implementation); const { returns, captures, generated } = options; const forms = declaration === undefined ? options.forms : declaredForms(declaration); + // Read through the same validator a host's registration is, where it is + // declared: a core default's offered spellings are part of what every + // execution resolves, so a malformed one refuses here rather than at the + // first table that admits the name. + const offered = generated === undefined ? undefined : admitGeneratedForms(generated, forms); + if (offered !== undefined && !offered.ok) { + throw new Error(`canonical core's <${name}> ${offered.error.message}`); + } return [ name, { @@ -116,7 +124,7 @@ function core( ...(returns === undefined ? {} : { returns }), ...(captures === undefined ? {} : { captures }), ...(forms === undefined ? {} : { forms }), - ...(generated === undefined ? {} : { generated: captureGenerated(generated) }), + ...(offered === undefined || !offered.ok ? {} : { generated: offered.value }), ...documented(documentation), fn, }, diff --git a/packages/core/src/evaluation-profile.ts b/packages/core/src/evaluation-profile.ts index 9cd53d0c1..bee539f53 100644 --- a/packages/core/src/evaluation-profile.ts +++ b/packages/core/src/evaluation-profile.ts @@ -59,7 +59,7 @@ 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 { offeredForm } from "./components/generated-metadata.ts"; +import { definitionGeneratedRefusal, offeredForm } from "./components/generated-metadata.ts"; import type { GeneratedEffect } from "./components/generated-metadata.ts"; import type { FunctionComponent, FunctionComponentDefinition, Json, PropsSchema } from "./types.ts"; @@ -1008,6 +1008,16 @@ function sealEntry(entry: PreparedEntry, sealed: ReadonlyMap): Operation { +function* admitting(name: string, admission: Operation): Operation { try { yield* admission; } catch (error) { diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index df42c3721..6e3516339 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -119,7 +119,11 @@ import type { ComponentResolution, ImportedDefinition } from "./components/compo import { isComponentName } from "./components/registration.ts"; import { CORE_ORIGIN, CORE_REGISTRY } from "./components/registry.ts"; import { fixedFilesystemDefinition } from "./components/fixed-filesystem.ts"; -import { isGeneratedIdentity, offeredForm } from "./components/generated-metadata.ts"; +import { + definitionGeneratedRefusal, + isGeneratedIdentity, + offeredForm, +} from "./components/generated-metadata.ts"; import { createBlockCounter, expandSegmentsWithin } from "./expand.ts"; import { extendPath } from "./expansion.ts"; import { prepareFetchRequest, requestRecord } from "./fetch-request.ts"; @@ -1009,6 +1013,12 @@ function selectedEntries( throw new GeneratedXmdError("a generated-XMD allowlist selected `read` with no read table."); } for (const observation of request.observations) { + // Read before anything is derived from it. A host pins this definition + // directly, so nothing has validated what it offers: an identity that is + // not a URL in the accepted namespace, or a spelling its own declaration + // does not accept, is refused here — before the admission record and + // before any body. + admitOffered(observation.name, observation.definition); // The spellings this component is offered to observe in, narrowed by the // host where it constrained the entry to the self-closing one. Derived // rather than assumed: a component offering one reading spelling admits @@ -1035,6 +1045,7 @@ function selectedEntries( ); } for (const mutation of mutations) { + admitOffered(mutation.name, mutation.definition); const forms = authoredForms(mutation.form); entries.push({ name: mutation.name, @@ -1050,6 +1061,24 @@ function selectedEntries( return entries; } +/** + * Hold one admitted definition to what its own declaration offers. + * + * Every live generated admission reads this, because every one of them is + * handed a definition nothing else validated: a provider's published answer was + * read where it was published, and a directly pinned entry is read here. The + * Json composition entry is not among them — a pure component offers no + * spelling for an owner to declare, and its identity is core's own. + */ +function admitOffered(name: string, definition: FunctionComponentDefinition): void { + const refusal = definitionGeneratedRefusal(definition); + if (refusal !== undefined) { + throw new GeneratedXmdError( + `a generated-XMD allowlist admitted <${name}> whose declaration ${refusal}.`, + ); + } +} + /** * The spellings one observation entry admits. * diff --git a/packages/core/src/invocation-forms.ts b/packages/core/src/invocation-forms.ts new file mode 100644 index 000000000..eef5b1773 --- /dev/null +++ b/packages/core/src/invocation-forms.ts @@ -0,0 +1,77 @@ +/** + * How an authored forms declaration may be written (spec §5.3). + * + * One contract, in a module of its own, because two kinds of declaration are + * held to it and neither may drift: what a registration or a factory says it + * accepts, and which of those spellings it offers for generated use + * (`components/generated-metadata.ts`). The second has to read the first, and + * reading it through the module that builds dispatchers would make the metadata + * validator depend on the component graph it is validating against. + */ + +import type { InvocationForm } from "./invocation-identity.ts"; + +/** + * The only ways a forms declaration may be written. + * + * A closed list rather than a set membership test, because the *order* is part + * of the declaration: one canonical spelling per meaning means two entries can be + * compared without normalizing, and a reader never has to wonder whether + * `["paired", "self-closing"]` said something different. + */ +const CANONICAL_FORMS: readonly (readonly InvocationForm[])[] = [ + ["self-closing"], + ["paired"], + ["self-closing", "paired"], +]; + +/** + * Why `forms` is not a canonical declaration, or `undefined` when it is. + * + * Omission is canonical and means both forms, which is what every declaration + * meant before forms could be written. Anything present is held to the list + * above, so an empty array, a reversed pair, a repeated member and a form no + * invocation has are each refused where the declaration is made rather than + * reaching a document as documentation nothing can act on. + * + * A reason rather than a thrown error, because the callers raise different + * kinds of failure and each owns its own sentence. `unknown` rather than the + * declared type, because a host crosses this boundary from JavaScript. + */ +export function formsRefusal(forms: unknown): string | undefined { + if (forms === undefined) { + return undefined; + } + const canonical = + Array.isArray(forms) && + CANONICAL_FORMS.some( + (accepted) => + accepted.length === forms.length && accepted.every((form, index) => form === forms[index]), + ); + if (canonical) { + return undefined; + } + return ( + `declares forms ${JSON.stringify(forms)}: the accepted declarations are ` + + `["self-closing"], ["paired"] and ["self-closing", "paired"], or none at all for both. ` + + "A form is written once, and a pair is written self-closing first" + ); +} + +/** + * The forms a declaration accepts, having passed `formsRefusal()`. + * + * `undefined` for genuine omission, which means both — the one reading that + * widens anything, and the reason a malformed value is refused above rather + * than treated as absent here. + */ +export function acceptedForms(forms: unknown): readonly InvocationForm[] | undefined { + if (!Array.isArray(forms)) { + return undefined; + } + const accepted = CANONICAL_FORMS.find( + (candidate) => + candidate.length === forms.length && candidate.every((form, index) => form === forms[index]), + ); + return accepted === undefined ? undefined : [...accepted]; +} diff --git a/packages/core/src/invocation-identity.ts b/packages/core/src/invocation-identity.ts index 1909b20eb..cf0e0d467 100644 --- a/packages/core/src/invocation-identity.ts +++ b/packages/core/src/invocation-identity.ts @@ -52,7 +52,7 @@ import { useScope } from "effection"; import type { Operation, Scope } from "effection"; import { printErrors, printsErrors } from "./component-failures.ts"; import { documentationOf } from "./components/documentation.ts"; -import { captureGenerated } from "./components/generated-metadata.ts"; +import { admitGeneratedForms } from "./components/generated-metadata.ts"; import type { GeneratedForms } from "./components/generated-metadata.ts"; import type { ComponentDocumentation } from "./components/documentation.ts"; import type { SyntaxReference } from "./syntax-reference.ts"; @@ -635,53 +635,6 @@ export function isFormDispatcher(fn: unknown): boolean { * answer, no method on the object handed in, and nothing a handler composed * takes part in choosing it. */ -/** - * The only ways a forms declaration may be written. - * - * A closed list rather than a set membership test, because the *order* is part - * of the declaration: one canonical spelling per meaning means two entries can be - * compared without normalizing, and a reader never has to wonder whether - * `["paired", "self-closing"]` said something different. - */ -const CANONICAL_FORMS: readonly (readonly InvocationForm[])[] = [ - ["self-closing"], - ["paired"], - ["self-closing", "paired"], -]; - -/** - * Why `forms` is not a canonical declaration, or `undefined` when it is. - * - * Omission is canonical and means both forms, which is what every declaration - * meant before forms could be written. Anything present is held to the list - * above, so an empty array, a reversed pair, a repeated member and a form no - * invocation has are each refused where the declaration is made rather than - * reaching a document as documentation nothing can act on. - * - * A reason rather than a thrown error, because the two callers raise different - * kinds of failure and each owns its own sentence. `unknown` rather than the - * declared type, because a host crosses this boundary from JavaScript. - */ -export function formsRefusal(forms: unknown): string | undefined { - if (forms === undefined) { - return undefined; - } - const canonical = - Array.isArray(forms) && - CANONICAL_FORMS.some( - (accepted) => - accepted.length === forms.length && accepted.every((form, index) => form === forms[index]), - ); - if (canonical) { - return undefined; - } - return ( - `declares forms ${JSON.stringify(forms)}: the accepted declarations are ` + - `["self-closing"], ["paired"] and ["self-closing", "paired"], or none at all for both. ` + - "A form is written once, and a pair is written self-closing first" - ); -} - /** * The forms a declaration accepts, in canonical order. * @@ -1068,6 +1021,32 @@ export interface IdentityInstallation { activate(): void; } +/** + * One declaration's generated metadata, read once and validated. + * + * A declared identity component crosses the installation boundary from a host, + * and the private and protected arms never pass registration — so this is where + * their metadata is read, and the value it answers with is the one the built + * definition retains. Validating the host's object and copying it again would + * be two readings of a value the host controls. + */ +function admittedGenerated(component: { + readonly name: string; + readonly generated?: GeneratedForms; + readonly forms?: readonly InvocationForm[]; +}): GeneratedForms | undefined { + if (component.generated === undefined) { + return undefined; + } + const admitted = admitGeneratedForms(component.generated, component.forms); + if (!admitted.ok) { + throw new ComponentInvocationError( + `the identity component "${component.name}" ${admitted.error.message}`, + ); + } + return admitted.value; +} + /** * Mint this execution's domains and build the implementations from them. * @@ -1112,6 +1091,7 @@ export function installIdentities( guarded.set(definition.name, definition); } for (const component of protectedComponents) { + const admitted = admittedGenerated(component); const domain = mintDomain(component.name); minted.set(component.name, domain); const implementation = componentRouting.implementation( @@ -1138,14 +1118,13 @@ export function installIdentities( ...(component.returns === undefined ? {} : { returns: component.returns }), ...(component.captures === undefined ? {} : { captures: component.captures }), ...(component.forms === undefined ? {} : { forms: component.forms }), - ...(component.generated === undefined - ? {} - : { generated: captureGenerated(component.generated) }), + ...(admitted === undefined ? {} : { generated: admitted }), ...documentationOf(component), fn: implementation, }); } for (const component of privateComponents) { + const admitted = admittedGenerated(component); const domain = mintDomain(component.name); minted.set(component.name, domain); const implementation = component.factory(domain.claim); @@ -1161,14 +1140,13 @@ export function installIdentities( ...(component.returns === undefined ? {} : { returns: component.returns }), ...(component.captures === undefined ? {} : { captures: component.captures }), ...(component.forms === undefined ? {} : { forms: component.forms }), - ...(component.generated === undefined - ? {} - : { generated: captureGenerated(component.generated) }), + ...(admitted === undefined ? {} : { generated: admitted }), ...documentationOf(component), fn: implementation, }); } for (const component of components) { + const admitted = admittedGenerated(component); const domain = mintDomain(component.name); minted.set(component.name, domain); // Built here, and held by identity: this exact function is what canonical @@ -1182,9 +1160,7 @@ export function installIdentities( ...(component.returns === undefined ? {} : { returns: component.returns }), ...(component.captures === undefined ? {} : { captures: component.captures }), ...(component.forms === undefined ? {} : { forms: component.forms }), - ...(component.generated === undefined - ? {} - : { generated: captureGenerated(component.generated) }), + ...(admitted === undefined ? {} : { generated: admitted }), ...documentationOf(component), fn: implementation, }); diff --git a/packages/core/tests/answer-identity.test.ts b/packages/core/tests/answer-identity.test.ts index a1f9643a7..9e29f2e5e 100644 --- a/packages/core/tests/answer-identity.test.ts +++ b/packages/core/tests/answer-identity.test.ts @@ -602,6 +602,25 @@ describe("Tier CIV — a publication belongs to one answer of one import", () => } }); + it("CIV23: a definition whose stated forms are not a declaration refuses", function* () { + const imports = owner(); + const provider = imports.provider(ORIGIN); + // The forms a definition states are read the way its metadata is, and only + // genuine omission means both: a value that is not a canonical declaration + // cannot be read as one by dropping what it says, because the offered + // spelling is checked against it. + for (const forms of ["paired", ["self-closing", "unknown"], []]) { + const supplied = Object.assign(bare(), { generated: OFFERED, forms }); + const resolution = resolving(imports, "Open", (resolution) => { + asking(provider, "Open", (request) => { + expect(refusalOf(() => request.answer(supplied))).toBeInstanceOf(AnswerIdentityError); + }); + return resolution; + }); + expect([forms, imports.identify(resolution, supplied)]).toEqual([forms, undefined]); + } + }); + it("CIV23: an answer carrying no metadata is published and read back", function* () { const imports = owner(); const provider = imports.provider(ORIGIN); diff --git a/packages/core/tests/evaluate-component.test.ts b/packages/core/tests/evaluate-component.test.ts index 53de85bda..cd06b9d94 100644 --- a/packages/core/tests/evaluate-component.test.ts +++ b/packages/core/tests/evaluate-component.test.ts @@ -2407,6 +2407,36 @@ describe("Tier FE14 — the chain answers, and the answer is held to what it off } }); + it("FE14: an answer stating forms this engine does not have refuses", function* () { + const files = recordedFiles(); + const stream = new InMemoryStream(); + // The same reading as the unit row, at the live boundary: the provider + // answers honestly with a definition whose stated forms are not a + // declaration this engine has, so the spelling it offers cannot be held to + // them and the publication refuses before the root import. + const A = implementation("Open", "A ran"); + const stating = Object.assign({ ...A.definition }, { forms: "paired" }); + + const failed = yield* refusal( + run( + OPEN, + [ + { + evaluation: admits(), + install: () => useRecordedFiles(files), + componentAnswers: [answerProvider("Open", stating)], + }, + ], + stream, + ), + ); + + expect(failed).toContain("the accepted declarations are"); + expect(admissions(yield* stream.readAll())).toHaveLength(0); + expect(A.invoked).toEqual([]); + expect(files.performed).toEqual([]); + }); + it("FE14: an answer offering no generated use of the admitted spelling refuses", function* () { const files = recordedFiles(); const stream = new InMemoryStream(); diff --git a/packages/core/tests/generated-owner-metadata.test.ts b/packages/core/tests/generated-owner-metadata.test.ts index 4e789cc2c..8e12d2b6d 100644 --- a/packages/core/tests/generated-owner-metadata.test.ts +++ b/packages/core/tests/generated-owner-metadata.test.ts @@ -36,7 +36,7 @@ import { Component } from "../src/component-api.ts"; import { registerComponents } from "../src/components/registration.ts"; import type { ComponentRegistration } from "../src/components/registration.ts"; import { - captureGenerated, + admitGeneratedForms, definitionGeneratedRefusal, generatedRefusal, isGeneratedIdentity, @@ -256,6 +256,92 @@ describe("Tier GOM — a declaration states what its spellings are offered as", }); // deno-lint-ignore require-yield + it("GOM1: an accessor is read once, and what was validated is what is retained", function* () { + // The check/use gap at the declaration boundary. The identity answers the + // accepted URL the first time it is read and `not-a-url` afterwards, so a + // registration that validated the declarer's object and then copied it + // again would install a value the check never saw. + const reads: string[] = []; + const alternating = { + "self-closing": { + get identity(): string { + reads.push("identity"); + return reads.length === 1 ? "https://executable.md/components/probe/read" : "not-a-url"; + }, + effect: "read" as const, + }, + }; + + yield* scoped(function* () { + expect(yield* refusal([registration({ generated: alternating })])).toBe(undefined); + + // One read, and the registry holds exactly it. The plant is live rather + // than inert: the very next read of the declarer's object says something + // else, which is what a second reading would have installed. + expect(reads).toEqual(["identity"]); + expect(yield* registered("Probe")).toEqual({ + "self-closing": { identity: "https://executable.md/components/probe/read", effect: "read" }, + }); + expect(alternating["self-closing"].identity).toBe("not-a-url"); + }); + }); + + it("GOM1: an accessor answering an unusable value first refuses the batch", function* () { + // The other side of the same read. What the one reading says is what the + // declaration states, so an accessor whose first answer is unusable refuses + // — and the batch installs nothing, however plausible a later reading is. + const reads: string[] = []; + const alternating = { + "self-closing": { + get identity(): string { + reads.push("identity"); + return reads.length === 1 ? "not-a-url" : "https://executable.md/components/probe/read"; + }, + effect: "read" as const, + }, + }; + + yield* scoped(function* () { + const failed = yield* refusal([ + registration({ name: "Fine", generated: READ_PROBE }), + registration({ generated: alternating }), + ]); + expect(failed).toContain("not an absolute URL"); + expect(reads).toEqual(["identity"]); + expect(yield* registered("Probe")).toBe(undefined); + expect(yield* registered("Fine")).toBe(undefined); + }); + }); + + it("GOM1: malformed stated forms are refused rather than filtered", function* () { + // Only genuine omission means both forms. A value that is not a canonical + // declaration cannot be read as one by dropping what it says: a string, an + // unknown member, an empty list and a reversed pair each describe a + // declaration this engine does not have, so a definition offering generated + // use of itself alongside one of them offers nothing it can be held to. + const offered = { + kind: "function", + name: "Probe", + props: { type: "object", properties: {}, additionalProperties: false }, + generated: READ_PROBE, + }; + for (const forms of ["paired", ["self-closing", "unknown"], [], ["paired", "self-closing"]]) { + expect([forms, definitionGeneratedRefusal({ ...offered, forms })]).toEqual([ + forms, + expect.stringContaining("the accepted declarations are"), + ]); + } + // Omission is the one reading that widens anything, and a canonical + // declaration holding the offered spelling is accepted. + expect(definitionGeneratedRefusal(offered)).toBe(undefined); + expect(definitionGeneratedRefusal({ ...offered, forms: ["self-closing"] })).toBe(undefined); + // And a definition offering nothing is not held to its forms at all, because + // it is asking for nothing identity or effect decides. + expect(definitionGeneratedRefusal({ kind: "function", name: "Probe", forms: "paired" })).toBe( + undefined, + ); + }); + it("GOM1: the refusal is a sentence a caller words for itself", function* () { // A sentence rather than a thrown error, because a registration, a factory // declaration and a provider's published answer each refuse in their own @@ -263,7 +349,14 @@ describe("Tier GOM — a declaration states what its spellings are offered as", expect(generatedRefusal(undefined, undefined)).toBe(undefined); expect(generatedRefusal(READ_PROBE, undefined)).toBe(undefined); expect(generatedRefusal(READ_PROBE, ["paired"])).toContain("does not accept"); - expect(captureGenerated(READ_PROBE)).toEqual(READ_PROBE); + // The copy and the verdict are one answer, so a caller retains exactly what + // was validated — frozen through, because a shared definition is read by + // every execution in the process. + const admitted = admitGeneratedForms(READ_PROBE, undefined); + expect(admitted.ok).toBe(true); + expect(admitted.ok ? admitted.value : undefined).toEqual(READ_PROBE); + expect(Object.isFrozen(admitted.ok ? admitted.value : undefined)).toBe(true); + expect(admitGeneratedForms("read", undefined).ok).toBe(false); }); }); diff --git a/packages/core/tests/generated-xmd.test.ts b/packages/core/tests/generated-xmd.test.ts index ffb6702cb..fc59e1408 100644 --- a/packages/core/tests/generated-xmd.test.ts +++ b/packages/core/tests/generated-xmd.test.ts @@ -2755,6 +2755,107 @@ describe("Tier GXC — a selection is not a grant", () => { expect(attempt.failure).toContain("one name with two definitions"); expect(admissions(attempt.events)).toHaveLength(0); }); + + /** + * A directly pinned definition is one nothing validated on the way in. + * + * A provider's answer is read where it is published and core's own owners + * where they are declared, but a host holding a definition and pinning it + * reaches this table with whatever its declaration says. So the declaration + * is read here, before the admission record exists and before any body: a + * host cannot admit a spelling its own definition offers no generated use of, + * admit one under something that is not an identity, or describe the forms it + * accepts in a shape this engine does not have. + */ + /** + * The probe, stating forms a declaration cannot state. + * + * Assembled rather than declared, because the point of each of these is that + * a host crossed this boundary from JavaScript with a value the declared + * shape does not describe. + */ + function stating(forms: unknown): FunctionComponentDefinition { + return Object.assign({ ...PROBE }, { forms }); + } + + const MALFORMED: readonly (readonly [string, FunctionComponentDefinition, string])[] = [ + [ + "an identity that is not an absolute URL", + { ...PROBE, generated: { "self-closing": { identity: "not-a-url", effect: "read" } } }, + "not an absolute URL", + ], + [ + "an identity naming a look-alike origin", + { + ...PROBE, + generated: { + "self-closing": { + identity: "https://executable.md.example.com/components/probe/read", + effect: "read", + }, + }, + }, + "not an absolute URL", + ], + [ + "a spelling its own declaration does not accept", + { ...PROBE, forms: ["paired"] }, + "does not accept", + ], + [ + "forms this engine does not have", + stating(["self-closing", "unknown"]), + "the accepted declarations are", + ], + [ + "forms stated as something other than a declaration", + stating("self-closing"), + "the accepted declarations are", + ], + ]; + + for (const [what, definition, refusal] of MALFORMED) { + it(`GXC2c: a directly pinned observation with ${what} is refused`, function* () { + const attempt = yield* evaluate( + request("\n", [pinnedComponent("Probe", definition, "component-answer")]), + ); + + // The host's own error, before the candidate is admitted: no record was + // appended, so nothing restores it and the component never ran. + expect([what, attempt.failure]).toEqual([what, expect.stringContaining(refusal)]); + expect([what, admissions(attempt.events).length]).toEqual([what, 0]); + expect([what, String(attempt.output ?? "")]).toEqual([what, ""]); + }); + } + + it("GXC2c: a directly pinned mutation is read the same way", function* () { + const attempt = yield* evaluate( + selecting("x\n", [probe()], { + allow: ["read", "write"], + mutations: [ + pinnedMutation( + "Nest", + { ...NEST, generated: { paired: { identity: "components/nest", effect: "write" } } }, + "paired", + "component-answer", + ), + ], + }), + ); + + expect(attempt.failure).toContain("not an absolute URL"); + expect(admissions(attempt.events)).toHaveLength(0); + }); + + it("GXC2c: the same table without the defect admits and runs", function* () { + // The positive control for the rows above: everything else about that table + // is what a host states, so each refusal is about the declaration it names. + const attempt = yield* evaluate(request("\n", [probe()])); + + expect(attempt.failure).toBe(undefined); + expect(attempt.output).toContain("probed"); + expect(admissions(attempt.events)).toHaveLength(1); + }); }); describe("Tier GXC — a name is not a form", () => { diff --git a/packages/core/tests/syntax-loaded-copy.test.ts b/packages/core/tests/syntax-loaded-copy.test.ts index 0898b9f91..e02f50521 100644 --- a/packages/core/tests/syntax-loaded-copy.test.ts +++ b/packages/core/tests/syntax-loaded-copy.test.ts @@ -25,6 +25,13 @@ * answers; what is only provable here is that a *real* second copy is one of * those answers. * + * Tier FE14 in the middle asks the positive form of the same question about + * *provider* code: a module loaded with no access to canonical core at all + * receives the registrar and the request this execution minted, publishes its + * own definition through `answer(definition)`, and a generated fragment runs + * exactly that answer. The refusals above say what a second copy cannot do; + * that row says what one can. + * * Tier FX at the end of this file asks the same question of the *other* shape * in the protected tier. ``, `` and `` are answered * with one ordinary definition built once per loaded copy rather than a body @@ -38,7 +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 { rm } from "@effectionx/fs"; +import { rm, writeTextFile } from "@effectionx/fs"; import { exec } from "@effectionx/process"; import { API, Files, useHostFiles } from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; @@ -51,7 +58,7 @@ import process from "node:process"; import { Component } from "../src/component-api.ts"; import { collect } from "../src/collect.ts"; import { executeInstalled } from "../host.ts"; -import type { ExecutionInstallation } from "../host.ts"; +import type { ComponentAnswerInstallation, ExecutionInstallation } from "../host.ts"; import { retainedSource } from "../src/root-source.ts"; import { SYNTAX_COMPONENT, props as syntaxProps } from "../src/components/Syntax.ts"; import { fixedFilesystemDefinition } from "../src/components/fixed-filesystem.ts"; @@ -130,6 +137,98 @@ function useSeparateCopy(): Operation { }); } +/** + * A provider module loaded with no access to canonical core. + * + * Written out and imported from a temporary directory, so the only things it + * can reach are the ones this execution hands it: the registrar its + * installation receives, and the request minted for each of its handler's + * invocations. It imports nothing — not core, not Effection — because + * publishing is one synchronous call on the request and delegation is one + * `yield*` of the operation it was given. + * + * The definition is the module's own object, built where the module is + * evaluated, and the identity it offers its self-closing spelling under is the + * module's own declaration. So what a fragment runs here is a provider's answer + * from outside this copy of core, admitted on the terms the declaration states. + */ +const LOADED_PROVIDER = ` +const invoked = []; +const definition = { + kind: "function", + name: "Loaded", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + generated: { + "self-closing": { identity: "https://executable.md/components/loaded/read", effect: "read" }, + }, + *fn() { + invoked.push("loaded answer ran"); + return "LOADED ANSWER RAN"; + }, +}; + +let settled = false; + +export function installation(origin) { + return { + origin, + *install(registrar) { + yield* registrar.around(function* (request, next) { + // It answers the capture and settles, which is what a provider is for: + // the profile resolves its name once, before any document code, and a + // fragment then runs the sealed snapshot. + if (request.name !== "Loaded" || settled) { + return yield* next(); + } + settled = true; + // The handed request, used for the one thing it is for. Nothing here + // holds a core module, a context, a symbol or a registry. + return request.answer(definition); + }); + }, + }; +} + +export function invocations() { + return [...invoked]; +} + +export function identity() { + return definition.generated["self-closing"].identity; +} +`; + +/** What that module exposes once it has been loaded. */ +interface LoadedProvider { + installation(origin: string): ComponentAnswerInstallation; + invocations(): readonly string[]; + identity(): string; +} + +function isLoadedProvider(value: unknown): value is LoadedProvider { + return ( + typeof value === "object" && + value !== null && + typeof Reflect.get(value, "installation") === "function" && + typeof Reflect.get(value, "invocations") === "function" + ); +} + +function useLoadedProvider(): Operation { + return resource(function* (provide) { + const directory = yield* until(mkdtemp(join(tmpdir(), "sl-provider-"))); + yield* ensure(() => rm(directory, { recursive: true, force: true })); + const module = join(directory, "provider.js"); + yield* writeTextFile(module, LOADED_PROVIDER); + const loaded: unknown = yield* until(import(`file://${module}`)); + if (!isLoadedProvider(loaded)) { + throw new Error("the loaded module does not expose a provider installation"); + } + yield* provide(loaded); + }); +} + /** * A handler that delegates the protected import and then answers with something * else. @@ -281,6 +380,53 @@ describe("Tier SYN — a separately loaded protected implementation", () => { }); }); +describe("Tier FE14 — a separately loaded provider publishes its own answer", () => { + it("FE14/U4-7: loaded provider code publishes through the handed request, and the fragment runs it", function* () { + 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. + const profile: ExecutionInstallation = { + evaluation: { + read: [{ kind: "component-answer", name: "Loaded", forms: ["self-closing"] }], + }, + componentAnswers: [loaded.installation("test://loaded")], + }; + + const output = yield* scoped(function* () { + return yield* collect( + yield* executeInstalled( + { + ...retainedSource("documents/root.md", `\\n'} />\n`), + stream, + includes: [], + }, + [profile], + ), + ); + }); + + // The loaded module's own body ran, once, and what it rendered is the + // fragment's output: the publication travelled with the object the chain + // returned, and canonical capture sealed that exact definition. + expect(String(output)).toContain("LOADED ANSWER RAN"); + expect(loaded.invocations()).toEqual(["loaded answer ran"]); + + // And the admission retains the identity that module declared for the + // spelling the fragment wrote — not a host-stated one, and not core's. + const admission = (yield* stream.readAll()).find( + (event) => event.type === "yield" && event.description.type === "generated_xmd", + ); + const retained = + admission?.type === "yield" && admission.result.status === "ok" + ? admission.result.value + : undefined; + expect(JSON.stringify(retained)).toContain(loaded.identity()); + expect(loaded.identity()).toBe("https://executable.md/components/loaded/read"); + }); +}); + describe("Tier FX — a fixed definition from a separately loaded copy", () => { it("FX3g: another copy's File dispatcher answers for nothing, and reaches no I/O", function* () { const copy = yield* useSeparateCopy(); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 9d17a8185..3034003ec 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1964,7 +1964,8 @@ run but are absent from the diagnostic trace. | `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/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`, `generatedRefusal()`, `captureGenerated()`, `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 (§5.3) | +| `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) | | `src/invocation.ts` | `withInvocation()`, `Invocation`, `InvocationTeardownError` — the component invocation boundary (§4.4) | | `src/expansion.ts` | `Expansion`, `getExpansion()` — what an executable element knows about its own expansion (§5.6) | | `src/invocation-identity.ts` | `ComponentInvocation`, `IdentityComponent`, `IdentityClaimant`, `ImportSelection`, `installIdentities()`, `issueInvocation()`, `ComponentInvocationError` — what a trusted host declares to an execution, the domains that execution mints for it, and the one-use issuance a claimant answers for (§5.6) | @@ -3333,7 +3334,13 @@ omitted spelling say the same thing for that spelling. It is validated where the declaration is made, as external data: exactly the two members, an identity whose origin is that namespace rather than a string beginning with it, one of the two effects, and a spelling the declaration itself -accepts. Carrying it grants nothing — a table still admits the name, canonical +accepts — read through the same canonical-forms contract `forms` is, so only a +genuinely omitted declaration means both spellings. One pass reads each member +once and answers with the copy, and that copy is what construction retains: a +member that answered one identity for the check and another for the copy would +otherwise install what the check never saw. Every live admission reads the +definition it was handed the same way, because a provider's published answer and +a host's directly pinned entry are both definitions nothing else validated. Carrying it grants nothing — a table still admits the name, canonical resolution still decides which definition answers for it, and an attempted generated use of a spelling with no metadata refuses. From 6d2a3992b769c9ae594a89e08940c363bec9bb81 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 14 Oct 2026 20:52:03 -0400 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=94=92=20Admit=20a=20pinned=20definit?= =?UTF-8?q?ion=20from=20the=20reading=20that=20was=20validated=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct generated admission validated an owned copy of a host's metadata and then threw it away: `observedForms`, `offeredIdentities` and the entry's own definition each read the host's object again. An identity answering the owner's URL on its first reading and `not-a-url` afterwards therefore appended an admission holding the second one — a record the reader of that same fresh journal then refused as unreadable history, after it had committed. Both public pinned constructors reached it. `admitOffered()` now keeps what it read. One pass copies the definition, reads each member of its metadata once into the frozen map this run owns, validates that map, and answers with both; the selected spellings, their URLs, the retained policy rows and the definition a fragment's import is answered with all come from it. The reading kept is kept per definition object, so a name admitted for two spellings is still one component with one implementation, and the implementation itself still crosses by reference. The refusal-only helper stays where the data is already this engine's — a provider's published answer is core's structured copy, and a captured profile entry holds that copy or one of core's own owners — and says so, so it is not reached for again as the only defense on a value a caller still controls. --- .../core/src/components/generated-metadata.ts | 15 ++- packages/core/src/generated-xmd.ts | 125 +++++++++++++----- packages/core/tests/generated-xmd.test.ts | 78 +++++++++++ 3 files changed, 182 insertions(+), 36 deletions(-) diff --git a/packages/core/src/components/generated-metadata.ts b/packages/core/src/components/generated-metadata.ts index 0364757a4..181546fd9 100644 --- a/packages/core/src/components/generated-metadata.ts +++ b/packages/core/src/components/generated-metadata.ts @@ -192,10 +192,17 @@ export function offeredForm( /** * Why a definition's own generated metadata is unusable, or nothing. * - * The check every *live* admission makes, wherever a definition reaches a - * generated table: a provider's published answer, a host's directly pinned - * entry, and a captured profile entry are all definitions nothing validated on - * the way in, so each is read here before anything is admitted or retained. + * For a definition whose data is already this engine's: a provider's published + * answer is core's own structured copy, and a captured profile entry holds + * either that copy or one of core's own owners. Reading such a value twice + * says the same thing twice, so a caller that only has to refuse can ask for + * the sentence alone. + * + * It is not enough on its own for a value a caller still controls. There, + * `admitGeneratedForms()` is what to use: the data it answers with is what the + * caller retains, so the reading that was validated is the reading that is + * admitted — which is why direct generated admission reads a pinned definition + * through it rather than through this (`generated-xmd.ts`). * * A definition offering generated use is held to its own stated forms, through * the same accepted-form contract a registration is. That value is read rather diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index 6e3516339..c76ec62df 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -119,11 +119,9 @@ import type { ComponentResolution, ImportedDefinition } from "./components/compo import { isComponentName } from "./components/registration.ts"; import { CORE_ORIGIN, CORE_REGISTRY } from "./components/registry.ts"; import { fixedFilesystemDefinition } from "./components/fixed-filesystem.ts"; -import { - definitionGeneratedRefusal, - isGeneratedIdentity, - offeredForm, -} from "./components/generated-metadata.ts"; +import { admitGeneratedForms, isGeneratedIdentity } from "./components/generated-metadata.ts"; +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"; @@ -993,6 +991,9 @@ function selectedEntries( allow: readonly GeneratedEffectClass[], ): Entry[] { const entries: Entry[] = []; + // One kept reading per definition object, so a name admitted for two + // spellings stays one component with one implementation. + const retained = new Map(); // Before either effect table and under every selection: composition is not a // class `allow` chooses between, so its entries are in the policy the same // way whatever the caller asked for, and in one canonical position. @@ -1013,22 +1014,24 @@ function selectedEntries( throw new GeneratedXmdError("a generated-XMD allowlist selected `read` with no read table."); } for (const observation of request.observations) { - // Read before anything is derived from it. A host pins this definition - // directly, so nothing has validated what it offers: an identity that is - // not a URL in the accepted namespace, or a spelling its own declaration - // does not accept, is refused here — before the admission record and - // before any body. - admitOffered(observation.name, observation.definition); + // Read once, before anything is derived from it. A host pins this + // definition directly, so nothing has validated what it offers — and + // everything below reads what this kept rather than the host's object: an + // identity that is not a URL in the accepted namespace, or a spelling its + // own declaration does not accept, is refused before the admission record + // and before any body, and a member that answers differently on a second + // reading never gets one. + const admitted = admitOffered(observation.name, observation.definition, retained); // The spellings this component is offered to observe in, narrowed by the // host where it constrained the entry to the self-closing one. Derived // rather than assumed: a component offering one reading spelling admits // that one, and a host cannot widen it to a spelling its owner never // offered as a read. - const forms = observedForms(observation); + const forms = observedForms(observation, admitted); entries.push({ name: observation.name, - identities: offeredIdentities(observation.name, observation.definition, forms, "read"), - definition: observation.definition, + identities: offeredIdentities(observation.name, admitted, forms, "read"), + definition: admitted.definition, ...(observation.dispatch === undefined ? {} : { dispatch: observation.dispatch }), forms, kind: observation.kind ?? "capability", @@ -1045,12 +1048,12 @@ function selectedEntries( ); } for (const mutation of mutations) { - admitOffered(mutation.name, mutation.definition); + const admitted = admitOffered(mutation.name, mutation.definition, retained); const forms = authoredForms(mutation.form); entries.push({ name: mutation.name, - identities: offeredIdentities(mutation.name, mutation.definition, forms, "write"), - definition: mutation.definition, + identities: offeredIdentities(mutation.name, admitted, forms, "write"), + definition: admitted.definition, ...(mutation.dispatch === undefined ? {} : { dispatch: mutation.dispatch }), forms, kind: mutation.kind ?? "capability", @@ -1062,21 +1065,77 @@ function selectedEntries( } /** - * Hold one admitted definition to what its own declaration offers. + * One directly pinned definition, as this run keeps it. + * + * What a host pinned is read once into this, and the admission uses it for + * everything afterwards: which spellings are admitted, the URL each of them + * runs under, the rows the policy retains, and the definition a fragment's + * import is answered with. Checking the host's object and then reading it again + * would be two readings of a value the host controls — the journal would hold + * the second one, which is not what was validated. + */ +interface AdmittedDefinition { + /** The definition this run holds, with the metadata below on it. */ + readonly definition: FunctionComponentDefinition; + /** The offered spellings, owned and frozen, or none for a definition offering none. */ + readonly generated: GeneratedForms | undefined; +} + +/** + * Hold one admitted definition to what its own declaration offers, and keep + * what it said. * * Every live generated admission reads this, because every one of them is * handed a definition nothing else validated: a provider's published answer was * read where it was published, and a directly pinned entry is read here. The * Json composition entry is not among them — a pure component offers no * spelling for an owner to declare, and its identity is core's own. + * + * Kept per definition *object*, not per entry, because a name admitted for two + * spellings is one component seen from two sides: `` and + * `…` are one definition, and two copies of it would be one name + * with two implementations. + * + * The implementation crosses by reference, because a function is not copyable + * and is the one thing a fragment must invoke as itself. */ -function admitOffered(name: string, definition: FunctionComponentDefinition): void { - const refusal = definitionGeneratedRefusal(definition); - if (refusal !== undefined) { +function admitOffered( + name: string, + definition: FunctionComponentDefinition, + retained: Map, +): AdmittedDefinition { + const held = retained.get(definition); + if (held !== undefined) { + return held; + } + const refuse = (reason: string): never => { throw new GeneratedXmdError( - `a generated-XMD allowlist admitted <${name}> whose declaration ${refusal}.`, + `a generated-XMD allowlist admitted <${name}> whose declaration ${reason}.`, ); - } + }; + // One read of every member, into this run's own object. + const copy: FunctionComponentDefinition = { ...definition }; + const badForms = formsRefusal(copy.forms); + if (badForms !== undefined) { + refuse(badForms); + } + const offered = + copy.generated === undefined + ? undefined + : admitGeneratedForms(copy.generated, acceptedForms(copy.forms)); + if (offered !== undefined && !offered.ok) { + refuse(offered.error.message); + } + const generated = offered === undefined || !offered.ok ? undefined : offered.value; + const admitted: AdmittedDefinition = Object.freeze({ + definition: Object.freeze({ + ...copy, + ...(generated === undefined ? {} : { generated }), + }), + generated, + }); + retained.set(definition, admitted); + return admitted; } /** @@ -1087,10 +1146,11 @@ function admitOffered(name: string, definition: FunctionComponentDefinition): vo * is a table admitting a component to observe that does not observe, so it * refuses here rather than at the first element that writes the name. */ -function observedForms(observation: GeneratedObservation): readonly AuthoredForm[] { - const offered = AUTHORED_FORMS.filter( - (form) => offeredForm(observation.definition.generated, form)?.effect === "read", - ); +function observedForms( + observation: GeneratedObservation, + admitted: AdmittedDefinition, +): readonly AuthoredForm[] { + const offered = AUTHORED_FORMS.filter((form) => admitted.generated?.[form]?.effect === "read"); const forms = observation.selfClosing === true ? offered.filter((form) => form === "self-closing") : offered; if (forms.length === 0) { @@ -1105,9 +1165,10 @@ function observedForms(observation: GeneratedObservation): readonly AuthoredForm /** * The identity each admitted spelling of one entry runs under. * - * Read off the definition the entry already holds, because the declaration that - * owns the implementation is what states it. Two refusals, both about the host - * table rather than about the fragment: a spelling its owner offers no + * Read off the reading this run kept, because the declaration that owns the + * implementation is what states it and a second reading of the host's object + * would be a different statement. Two refusals, both about the host table + * rather than about the fragment: a spelling its owner offers no * generated use of has no identity for a continuation to be compared against, * and a spelling offered as a read cannot be admitted in the write table — * what a spelling does belongs to the component, so a table placing it @@ -1115,13 +1176,13 @@ function observedForms(observation: GeneratedObservation): readonly AuthoredForm */ function offeredIdentities( name: string, - definition: FunctionComponentDefinition, + admitted: AdmittedDefinition, forms: readonly AuthoredForm[], effect: GeneratedEffectClass, ): ReadonlyMap { const identities = new Map(); for (const form of forms) { - const offered = offeredForm(definition.generated, form); + const offered = admitted.generated?.[form]; if (offered === undefined) { throw new GeneratedXmdError( `a generated-XMD allowlist admitted <${name}> in its ${form} form, and the declaration ` + diff --git a/packages/core/tests/generated-xmd.test.ts b/packages/core/tests/generated-xmd.test.ts index fc59e1408..ab19e0d74 100644 --- a/packages/core/tests/generated-xmd.test.ts +++ b/packages/core/tests/generated-xmd.test.ts @@ -2847,6 +2847,84 @@ describe("Tier GXC — a selection is not a grant", () => { expect(admissions(attempt.events)).toHaveLength(0); }); + /** + * The probe, with an identity that answers differently on a second reading. + * + * Assembled rather than declared, because the point is a member a host + * controls: `[[Get]]` answers the owner's URL once and something unusable + * afterwards, so a table that checked the host's object and then read it + * again to build the admission would retain a value the check never saw. + */ + function alternating( + form: "self-closing" | "paired", + effect: GeneratedEffectClass, + reads: string[], + ): FunctionComponentDefinition { + const offered = { + get identity(): string { + reads.push("identity"); + return reads.length === 1 + ? `https://executable.md/components/probe/${effect}` + : "not-a-url"; + }, + effect, + }; + return Object.assign({ ...PROBE }, { forms: [form], generated: { [form]: offered } }); + } + + it("GXC2c: a directly pinned observation is read once, and the admission holds it", function* () { + const reads: string[] = []; + const attempt = yield* evaluate( + request("\n", [ + pinnedComponent("Probe", alternating("self-closing", "read", reads), "component-answer"), + ]), + ); + + // One reading, and the record holds exactly it. The plant is live rather + // than inert: the next read of the host's object says `not-a-url`, which is + // what a second reading would have appended — and what the reader of that + // fresh record would then have refused, after the admission committed. + expect(reads).toEqual(["identity"]); + expect(attempt.failure).toBe(undefined); + expect(attempt.output).toContain("probed"); + expect(recordedNames(admittedFragments(attempt.events)[0])).toEqual([ + { + name: "Probe", + identity: "https://executable.md/components/probe/read", + form: "self-closing", + }, + ]); + }); + + it("GXC2c: a directly pinned mutation is read once, and the admission holds it", function* () { + const reads: string[] = []; + const attempt = yield* evaluate( + selecting("x\n", [probe()], { + allow: ["read", "write"], + mutations: [ + pinnedMutation( + "Writer", + alternating("paired", "write", reads), + "paired", + "component-answer", + ), + ], + }), + ); + + expect(reads).toEqual(["identity"]); + expect(attempt.failure).toBe(undefined); + const policy = recordedPolicy(admittedFragments(attempt.events)[0]); + const rows = isRecord(policy) && Array.isArray(policy.allowed) ? policy.allowed : []; + expect(rows).toContainEqual({ + name: "Writer", + form: "paired", + identity: "https://executable.md/components/probe/write", + kind: "component-answer", + effect: "write", + }); + }); + it("GXC2c: the same table without the defect admits and runs", function* () { // The positive control for the rows above: everything else about that table // is what a host states, so each refusal is about the declaration it names. From 021fa6c2dfdd497d3b9ce82773b8f6317c3cb3b9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 15 Oct 2026 05:51:59 -0400 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=94=92=20Keep=20a=20declared=20compon?= =?UTF-8?q?ent's=20offer=20through=20trusted=20capture=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host may declare a component to an execution instead of registering one, and capture reads every member of that declaration once because reading one later would be reading it after an installation ran. The member this slice adds was not among them: `retainedIdentityComponent()` copied the name, origin, schemas, forms, prose and factory, so a valid offer was dropped before the implementation was built. The supported path then failed on honest input — the provider published the constructed definition, and sealing refused the entry because the definition it was handed offered nothing. Ordinary registration of the same declaration worked. Capture now reads the offer through the validator that owns the contract, and retains what it answers with. It is the one member whose copy is a grant — a URL and an effect decide which implementation a continuation admits — so a reading taken later, or a nested object left live for construction to be handed, would be a grant the declaration never made. The forms are read first, into the copy, so the offer is held against the spellings this execution accepts. The built implementation, the admitted rows and the definition a fragment runs all come from that one reading, and a declaration this engine cannot read refuses where it is captured, before anything is built, asked or written down. Four rows in the existing GOM tier drive it through a real execution: the host declares, the execution builds, the provider publishes that exact object, and the fragment names the offered spelling. Dropping the member again fails the three positive rows with the refusal this round reported; retaining the host's object instead of the validated reading admits the substituted URL. --- packages/core/src/execute.ts | 18 +- packages/core/src/invocation-identity.ts | 7 +- .../tests/generated-owner-metadata.test.ts | 237 ++++++++++++++++++ specs/executable-mdx-spec.md | 8 +- 4 files changed, 265 insertions(+), 5 deletions(-) diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 7698ec4cc..2967ab7fd 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -136,6 +136,7 @@ import type { import { documentationOf } from "./components/documentation.ts"; import { registerComponents } from "./components/registration.ts"; import { + admittedGenerated, declaredForms, formDispatcher, installFormSyntax, @@ -3318,15 +3319,28 @@ function reconcile(document: Result | undefined, teardown: Error | undefin * Shared by the components a host declares to an execution and by the private * declarations one declared Markdown component carries, so a member added to * one is not silently dropped from the other. + * + * The generated use each form is offered for is read through the validator that + * owns that contract, because it is the one member whose copy is a grant: an + * identity and an effect decide which implementation a continuation admits, so + * a reading taken later — or a nested object left live for the factory to be + * handed — would be a grant this declaration never made. What the validator + * answers with is what is retained, and what the forms are built from. */ function retainedIdentityComponent(component: IdentityComponent): IdentityComponent { + const name = component.name; + // Read before the metadata, so the forms the offer is held against are the + // forms this execution accepts the component in. + const forms = component.forms === undefined ? undefined : Object.freeze([...component.forms]); + const generated = admittedGenerated({ name, generated: component.generated, forms }); return Object.freeze({ - name: component.name, + name, origin: component.origin, props: detachedSchema(component.props), ...(component.returns === undefined ? {} : { returns: detachedSchema(component.returns) }), ...(component.captures === undefined ? {} : { captures: [...component.captures] }), - ...(component.forms === undefined ? {} : { forms: [...component.forms] }), + ...(forms === undefined ? {} : { forms }), + ...(generated === undefined ? {} : { generated }), ...documentationOf(component), factory: component.factory.bind(component), }); diff --git a/packages/core/src/invocation-identity.ts b/packages/core/src/invocation-identity.ts index cf0e0d467..f41f055ea 100644 --- a/packages/core/src/invocation-identity.ts +++ b/packages/core/src/invocation-identity.ts @@ -1029,8 +1029,13 @@ export interface IdentityInstallation { * their metadata is read, and the value it answers with is the one the built * definition retains. Validating the host's object and copying it again would * be two readings of a value the host controls. + * + * An execution captures the components it was declared before anything it + * installs runs, so it reads the host's metadata through this too (`execute.ts`) + * and hands the captured value back here. Reading core's own frozen copy a + * second time says what the first reading said. */ -function admittedGenerated(component: { +export function admittedGenerated(component: { readonly name: string; readonly generated?: GeneratedForms; readonly forms?: readonly InvocationForm[]; diff --git a/packages/core/tests/generated-owner-metadata.test.ts b/packages/core/tests/generated-owner-metadata.test.ts index 8e12d2b6d..9c1b07658 100644 --- a/packages/core/tests/generated-owner-metadata.test.ts +++ b/packages/core/tests/generated-owner-metadata.test.ts @@ -29,9 +29,15 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; +import { InMemoryStream } from "@executablemd/durable-streams"; import { scoped } from "effection"; import type { Operation } from "effection"; +import { collect } from "../src/collect.ts"; +import { executeInstalled } from "../host.ts"; +import type { ComponentAnswerInstallation, ExecutionInstallation } from "../host.ts"; +import { retainedSource } from "../src/root-source.ts"; +import type { IdentityComponent } from "../src/invocation-identity.ts"; import { Component } from "../src/component-api.ts"; import { registerComponents } from "../src/components/registration.ts"; import type { ComponentRegistration } from "../src/components/registration.ts"; @@ -480,3 +486,234 @@ describe("Tier GOM — a shared definition's metadata cannot be edited", () => { }); }); }); + +/** + * Tier GOM4 — a declared component's offer survives trusted capture. + * + * The other declaration path. A host may declare a component to the execution + * instead of registering one, and the execution builds the implementation from + * that declaration's factory — so the offer has to cross capture, which reads + * every member of the host's object once and holds what it read. A member read + * later would be read after an installation ran, and this is the member whose + * reading is a grant: a URL and an effect decide which implementation a + * continuation admits. + * + * Every row drives a real execution: the host declares the component, the + * execution builds it, a provider publishes that exact built object, and the + * fragment names it in the spelling its declaration offers. What the rows read + * is the admission the run retained, because a refusal assertion would pass for + * a run that admitted the wrong URL and happened to fail later. + */ +describe("Tier GOM — a declared component's offer survives capture", () => { + const ROOT_PATH = "owner.md"; + /** A document naming the declared component in the spelling it offers. */ + const NAMING_PROBE = `\\n'} />\n`; + /** What the host's object says after capture has taken its reading. */ + const SUBSTITUTE: GeneratedForms = { + "self-closing": { + identity: "https://executable.md/components/probe/substituted", + effect: "read", + }, + }; + + /** One component a host declares, whose implementation its factory builds. */ + function declaredProbe(generated: GeneratedForms | undefined): IdentityComponent { + return { + name: "Probe", + origin: "test://owner", + props: { type: "object", properties: {}, additionalProperties: false }, + forms: ["self-closing"], + ...(generated === undefined ? {} : { generated }), + factory() { + // deno-lint-ignore require-yield + return function* (): Operation { + return "the factory body ran"; + }; + }, + }; + } + + /** + * A provider publishing the implementation this execution built. + * + * It holds no definition of its own: what a host-declared component resolves + * to is the object the execution built from the factory, so the provider + * reads that from the registry and publishes it. A row reads the published + * object to say what construction kept. + */ + function publishing(published: FunctionComponentDefinition[]): ComponentAnswerInstallation { + let settled = false; + return { + origin: "test://publisher", + *install(registrar) { + yield* registrar.around(function* (request, next) { + if (request.name !== "Probe" || settled) { + return yield* next(); + } + settled = true; + const built = (yield* currentRegistry()).get("Probe")?.default?.definition; + if (built === undefined || built.kind !== "function") { + throw new Error("the execution built no implementation for the declared component"); + } + published.push(built); + return request.answer(built); + }); + }, + }; + } + + /** One host: a declared component, an entry admitting it, and its provider. */ + function host( + component: IdentityComponent, + published: FunctionComponentDefinition[], + install?: () => Operation, + ): ExecutionInstallation { + return { + components: [component], + evaluation: { read: [{ kind: "component-answer", name: "Probe", forms: ["self-closing"] }] }, + componentAnswers: [publishing(published)], + ...(install === undefined ? {} : { install }), + }; + } + + function run( + installations: readonly ExecutionInstallation[], + stream: InMemoryStream, + ): Operation { + return scoped(function* () { + return yield* collect( + yield* executeInstalled({ ...retainedSource(ROOT_PATH, NAMING_PROBE), stream }, [ + ...installations, + ]), + ); + }); + } + + function isRecord(value: Json | undefined): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + /** The row this run's admission retained for the name it admitted. */ + function* admittedRow(stream: InMemoryStream): Operation { + const events = yield* stream.readAll(); + const event = events.find( + (held) => held.type === "yield" && held.description.type === "generated_xmd", + ); + const result = event?.type === "yield" ? event.result : undefined; + if (result === undefined || result.status !== "ok" || !isRecord(result.value)) { + throw new Error("the run retained no admission"); + } + const policy = result.value.policy; + if (!isRecord(policy) || !Array.isArray(policy.allowed)) { + throw new Error("the admission recorded no policy"); + } + // Trusted composition leads every policy, so the row the selection + // contributed is the one after it. + const row = policy.allowed.find((held) => isRecord(held) && held.name === "Probe"); + if (!isRecord(row)) { + throw new Error("the admission admitted nothing for the declared component"); + } + return row; + } + + /** What an admission retains for a form offered as a read. */ + const PROBE_ROW = { + name: "Probe", + form: "self-closing", + identity: "https://executable.md/components/probe/read", + kind: "component-answer", + effect: "read", + }; + + it("GOM4: a host's factory declaration runs the spelling it offered", function* () { + const stream = new InMemoryStream(); + const published: FunctionComponentDefinition[] = []; + + const output = yield* run([host(declaredProbe(READ_PROBE), published)], stream); + + // The built implementation carries its declaration's offer, the admission + // names that URL and effect, and the body a factory supplied is what ran. + expect(published[0]?.generated).toEqual(READ_PROBE); + expect(yield* admittedRow(stream)).toEqual(PROBE_ROW); + expect(String(output)).toContain("the factory body ran"); + }); + + it("GOM4: capture reads the offer once, and construction uses that reading", function* () { + // The check/use gap at the installation boundary, where the host also gets + // to run code in between: the accessor answers the accepted URL the first + // time and a substituted one afterwards, so a construction that read the + // host's object again would build a component offering a URL capture never + // validated. + const reads: GeneratedForms[] = []; + const component: IdentityComponent = { + ...declaredProbe(undefined), + get generated(): GeneratedForms { + reads.push(READ_PROBE); + return reads.length === 1 ? READ_PROBE : SUBSTITUTE; + }, + }; + const stream = new InMemoryStream(); + const published: FunctionComponentDefinition[] = []; + + const output = yield* run([host(component, published)], stream); + + // What the one reading said is what was built and what was admitted — and + // one reading is all there was. The plant is live rather than inert: the + // very next reading of the host's object offers the substituted URL. + expect(published[0]?.generated).toEqual(READ_PROBE); + expect(yield* admittedRow(stream)).toEqual(PROBE_ROW); + expect(reads).toHaveLength(1); + expect(component.generated).toEqual(SUBSTITUTE); + expect(String(output)).toContain("the factory body ran"); + }); + + it("GOM4: an offer replaced from inside install() has replaced nothing", function* () { + // A host's installation callback runs before the declared components are + // built, so this is the window a live reference would be read through. + const offers: { generated: GeneratedForms } = { generated: READ_PROBE }; + const component: IdentityComponent = { + ...declaredProbe(undefined), + get generated(): GeneratedForms { + return offers.generated; + }, + }; + const stream = new InMemoryStream(); + const published: FunctionComponentDefinition[] = []; + + const output = yield* run( + [ + // deno-lint-ignore require-yield + host(component, published, function* () { + offers.generated = SUBSTITUTE; + }), + ], + stream, + ); + + expect(offers.generated).toEqual(SUBSTITUTE); + expect(published[0]?.generated).toEqual(READ_PROBE); + expect(yield* admittedRow(stream)).toEqual(PROBE_ROW); + expect(String(output)).toContain("the factory body ran"); + }); + + it("GOM4: an unusable offer refuses the execution, having run nothing", function* () { + const stream = new InMemoryStream(); + const published: FunctionComponentDefinition[] = []; + const unusable = { "self-closing": { identity: "not-a-url", effect: "read" as const } }; + + let refused = ""; + try { + yield* run([host(declaredProbe(unusable), published)], stream); + } catch (error) { + refused = error instanceof Error ? error.message : String(error); + } + + // Read where it is captured, so a declaration this engine cannot read + // refuses before an implementation is built, a provider is asked or + // anything is written down. + expect(refused).toContain('the identity component "Probe" offers its self-closing form'); + expect(refused).toContain("not an absolute URL"); + expect(published).toEqual([]); + expect(yield* stream.readAll()).toEqual([]); + }); +}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 3034003ec..00381faf0 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -3340,7 +3340,11 @@ once and answers with the copy, and that copy is what construction retains: a member that answered one identity for the check and another for the copy would otherwise install what the check never saw. Every live admission reads the definition it was handed the same way, because a provider's published answer and -a host's directly pinned entry are both definitions nothing else validated. Carrying it grants nothing — a table still admits the name, canonical +a host's directly pinned entry are both definitions nothing else validated. A +component a host declares to an execution (§5.6) crosses the same boundary: the +execution reads the offer where it reads the rest of that declaration, before +anything the host installs runs, and the implementation it builds from the +declared factory offers what that one reading said. Carrying it grants nothing — a table still admits the name, canonical resolution still decides which definition answers for it, and an attempted generated use of a spelling with no metadata refuses. @@ -13118,7 +13122,7 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §§8.4 and 9. | 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` | -| GOM1–GOM3 | 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. In `generated-owner-metadata.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 From a26adf1b1e531becb08951cc4de15e7fd4a79fe4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 15 Oct 2026 07:04:20 -0400 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=93=9D=20Say=20what=20an=20admitted?= =?UTF-8?q?=20name=20runs,=20where=20the=20contract=20is=20specified=20(#1?= =?UTF-8?q?81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chapter that owns sealed evaluation entries still specified the protocol this slice replaced: a host entry stating an expected identity, one identity per name across its spellings, a provider supplying a key and a revision, and capability entries reconciling released untagged records through stated aliases. It now says what the entry states — the name and the spellings — and what the publication states: core's copy of the definition, with each spelling's URL and effect read off the declaration that owns the implementation. Every request and window lifetime constraint is kept, including the first-publication baseline a later window cannot rebaseline. Two paragraphs above it claimed `` is admitted only to read and that mutation admission is unbuilt, which the write table in the same chapter contradicts. The profile's own comments described the private filesystem route that is gone. An admitted capability is core's ordinary component: it resolves the contextual working directory and performs its operation through `API.Files` or `API.Fetch` under the installed provider and the consuming scope's Permissions, rather than over an operation the host handed the profile. The `write` table documented one set of entries for both standard profiles, where the ordinary one admits `` and the workflow one admits ``. The reference gained the two shapes a reader had to infer from tests: a declaration offering both of its spellings, keyed by spelling with the literal effects, and a provider publishing that definition through `request.answer()`. Historical asides go with them — what the metadata used to be stated beside, and what an origin has always been — leaving the present role each one has. --- architecture.md | 78 ++++++++++--------- .../src/components/component-resolution.ts | 14 ++-- .../core/src/components/generated-metadata.ts | 13 ++-- packages/core/src/evaluation-profile.ts | 69 ++++++++-------- specs/executable-mdx-spec.md | 36 +++++++++ 5 files changed, 129 insertions(+), 81 deletions(-) diff --git a/architecture.md b/architecture.md index 2f2d5683f..9ff280e1e 100644 --- a/architecture.md +++ b/architecture.md @@ -3707,15 +3707,16 @@ 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. -`` is admitted only in its self-closing read form. The component reads -when it has no content and writes when it has some, so the two forms are two -identities and the constraint travels with the pinned one, decided in the +``'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 +self-closing spelling alone, and which one an element wrote is decided in the whole-fragment preflight rather than inside the component after earlier elements -have run. +have run. A repository component that takes the name `File` is admitted as +neither spelling. -Mutation-proposal admission and workflow-bundled Markdown component admission -remain unbuilt. Directory registration is not among them: a workflow Agent is -given no directory to register. +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 @@ -3747,21 +3748,29 @@ 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, and -states only the identity a provider must have registered for it; 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 identity owner about the exact final answer in one call. -That call answers with the registered identity *and* core's own copy of what was -registered, taken when the identity was registered: 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 identity is then compared whole, and the copy — not the answer — is -sealed behind the same revocation. - -One name states one identity, however many forms and tables hold it: two entries under -one name are the two spellings of one component and share one lookup and one sealed -implementation, while a second identity for that name, or a name held as both a +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. + +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. @@ -3769,28 +3778,27 @@ Provider installation and the right to answer one occurrence are separate: insta receives a registrar that installs import middleware, and every invocation of that middleware receives a fresh request fixed to the exact name, position, provider installation, origin and resolution-window object it was asked under. Only that request -may register an identity, and it states the answer plus key and revision without -restating the name. Canonical execution closes the request synchronously when its +may publish, and it presents the definition it is answering with — not a name, a key or +a revision beside it. Canonical execution closes the request synchronously when its handler returns, fails or is cancelled; an outer request remains live while it delegates -and may register a replacement after the inner handler returns. Enclosing resolution +and may publish a replacement after the inner handler returns. Enclosing resolution still closes in its own `finally` on success, fallback, failure or cancellation. A -registration therefore requires the execution, request and exact current window to +publication therefore requires the execution, request and exact current window to remain active, the request's fixed name to match that window, and that provider -installation not to have stated a different answer in the window. +installation not to have published a different answer in the window. Publishing the same +object again is what a reusable provider does for every import it answers, so a +publication is compared against the copy taken the first time: an object edited after it +was published cannot be rebaselined by a later window. -Identification takes the expected window explicitly and accepts only a registration +Identification takes the expected window explicitly and accepts only a publication recorded for that exact object and name, so a stale request cannot answer a later same-name resolution or retag itself while another name is live. The installation is reusable: one provider may answer several admitted names and repeated resolutions through distinct requests. All of this state is held in ordinary private closures — no Context, shared symbol, public brand or module-global registry — -and an unidentified middleware replacement remains a valid ordinary import answer -outside fragment evaluation. - -A capability entry also states the exact version-1 identity strings it succeeds, which -is the only thing a released untagged record reconciles against; a component answer -states none. A capability-only profile performs no such lookup at all. +and a middleware replacement that publishes nothing remains a valid ordinary import +answer outside fragment evaluation. Resolution happens at capture and never again: a fragment runs the sealed snapshot, a continuation resolves once more in its own capture and reconciles before any effect, and @@ -3800,7 +3808,7 @@ generated import, which only canonical execution answers. A trusted layering control may enter one package bootstrap through an inherited layer and an execution-local one — documentation, registrations and the provider together — and the repeat is additive: the outer entry observes and delegates rather than -registering a second identity for an implementation that already states what it is, and +publishing a second answer for an implementation one publication already settled, and a non-identical documentation overlap on one owner and component still refuses at the child collection boundary, before the root import and therefore before the provider is asked at all. diff --git a/packages/core/src/components/component-resolution.ts b/packages/core/src/components/component-resolution.ts index 4554d6e3c..b067d6377 100644 --- a/packages/core/src/components/component-resolution.ts +++ b/packages/core/src/components/component-resolution.ts @@ -204,19 +204,19 @@ export interface IdentifiedAnswer { * cannot be forged into being the one the caller opened. * * A reusable provider returns one immutable definition to every import it - * answers. Keying the identity record by the object alone made the first - * resolution part of the object's permanent identity and refused the same - * definition the second time, which is a provider being punished for not - * copying itself. + * answers, so the record outside the window keys the object alone and holds + * what every resolution of it has in common. Keying the whole record by the + * object would make the first resolution part of that object's permanent + * identity, and refuse the same definition the second time. */ interface Answer { readonly name: string; /** * The installation origin that published it. * - * Provenance and diagnostic information, which is all an origin has ever - * been: it says which provider installation spoke, and it is no part of what - * the implementation is — that is the definition's own metadata. + * Provenance: it says which provider installation spoke, for a reader and a + * documentation symbol. It is no part of what the implementation is — that is + * the definition's own metadata. */ readonly origin: string; /** Which provider installation published it, so a second cannot overwrite. */ diff --git a/packages/core/src/components/generated-metadata.ts b/packages/core/src/components/generated-metadata.ts index 181546fd9..7029dd88b 100644 --- a/packages/core/src/components/generated-metadata.ts +++ b/packages/core/src/components/generated-metadata.ts @@ -3,14 +3,13 @@ * * A generated fragment names a component, and what that name is admitted as — * which URL a continuation is compared against, and whether the form observes - * or mutates — belongs to the declaration that owns the implementation. It used - * to be stated beside the definition, by whichever host assembled the table, so - * one component's two spellings were two host-stated grants and a host could - * describe an effect the implementation does not have. + * or mutates — belongs to the declaration that owns the implementation. So the + * metadata travels on the declaration, per authored form, and every table that + * admits a name reads it off the definition it already holds: a host decides + * which names and spellings a fragment may write, and never what writing one + * runs as. * - * So the metadata travels on the declaration, per authored form, and every - * table that admits a name reads it off the definition it already holds. A - * component that offers no generated use carries none, which is the ordinary + * A component that offers no generated use carries none, which is the ordinary * case: metadata is what makes a form offerable, and offering none takes * nothing away from authored use. * diff --git a/packages/core/src/evaluation-profile.ts b/packages/core/src/evaluation-profile.ts index bee539f53..8ccf60383 100644 --- a/packages/core/src/evaluation-profile.ts +++ b/packages/core/src/evaluation-profile.ts @@ -4,8 +4,8 @@ * * `` 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 `` uses only - * operations already captured in this profile, selected by `allow`. So the + * 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. * @@ -14,10 +14,10 @@ * 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 live operation read and bound - * once — 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. + * — 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 @@ -74,15 +74,15 @@ export type FragmentForm = "self-closing" | "paired"; * this implementation come from", which is exactly the question an admission * has to settle once. * - * Neither arm carries a function. A capability names an operation canonical - * core supplies the body for; 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 structural - * identity, the forms and the limits. + * 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 core supplies the body for, bound to the host's own operations. */ +/** An entry canonical core supplies the component for. */ export interface CapabilityEntry { readonly kind: "capability"; /** The name a fragment writes. */ @@ -108,14 +108,15 @@ export interface CapabilityEntry { */ readonly description?: string; /** - * Which captured operation this entry runs. + * Which of core's own components this entry runs. * - * Not a definition, because a definition is where a provider lookup would go. - * A host names the capability and hands its private operations to the - * profile; canonical capture reads each one off exactly once and closes - * core's own body over the bound result. A host therefore chooses *what a - * fragment may do* and never *how it reaches it* - * (`fragment-capabilities.ts`). + * 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; /** @@ -184,9 +185,11 @@ export interface FragmentEvaluationInput { /** * The entries the `write` class resolves to, when this host offers the class. * - * For both standard profiles this is exactly paired ``, paired `` - * and self-closing ``. It does not include the read table: - * `write` selects mutation and nothing else. + * 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. */ @@ -210,10 +213,12 @@ export interface FragmentEvaluationInput { * the two — before the first effect, rather than inside a body that has already * been entered. * - * An admitted read reaches the `readTextFile` the host handed the profile and - * nothing else. Under a workflow run that is the transaction-bound operation, - * so the read still crosses the run's own transaction; what it no longer does - * is resolve a provider through the contextual API at the moment it runs. + * 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"); @@ -222,12 +227,12 @@ export function fileReadEntry(): CapabilityEntry { /** * Core's ``, admitted to select paths and nothing else. * - * A value entry: the definition core builds for it declares what it binds, so a - * generated `` written without `as` is refused before the host's - * provider is asked to traverse anything. What it selects is bounded the same - * way a read is — the captured `globFiles` operation and the captured working - * directory — so a search never reaches `API.Files` or `Env.cwd`, and the paths - * it answers with are the ones that provider produced. + * 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"); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 00381faf0..cff5dc977 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -3348,6 +3348,42 @@ declared factory offers what that one reading said. Carrying it grants nothing resolution still decides which definition answers for it, and an attempted generated use of a spelling with no metadata refuses. +A declaration that offers both of its spellings states both, keyed by the +authored spelling: + +```typescript +yield* registerComponents([ + { + name: "Notes", + origin: "@example/notes", + forms: ["self-closing", "paired"], + props: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }, + generated: { + "self-closing": { + identity: "https://executable.md/components/notes/read", + effect: "read", + }, + paired: { identity: "https://executable.md/components/notes/write", effect: "write" }, + }, + *fn(props, invocation) { + return yield* notes(props, invocation); + }, + }, +]); +``` + +A provider publishes that same definition, and states nothing beside it — the +identity and the effect of each spelling are already on it: + +```typescript +yield* registrar.around(function* (request, next) { + if (request.name !== "Notes") { + return yield* next(); + } + return request.answer(notesDefinition); +}); +``` + A registration may also carry `description`, `as` and `context` — the same three descriptive fields Markdown frontmatter carries, with the same meaning and the same absence of behavior. Every component a first-party package registers From 4c05e45b468a80db7370a8163d82ce20ede94262 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 15 Oct 2026 07:35:52 -0400 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=9A=A8=20Hold=20the=20workflow=20case?= =?UTF-8?q?s=20to=20the=20current=20admission,=20and=20cache=20the=20npm?= =?UTF-8?q?=20build=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI jobs failed on this branch, and the local feedback set could not see any of them. Two were suites outside it still asserting the protocol this slice replaced. WGAC17 read the retained Dir admission as `"key":"Dir"` with `"revision":"3"`, and then moved the revision to prove a continuation admitted under the earlier grant refuses. Both halves now read what the record holds: the name, the spelling, the identity core's own declaration offers that spelling as, and the table that admitted it — and the refusal half moves the identity to another URL in the accepted namespace, so the record stays readable and what refuses is still the grant comparison rather than unreadable history. It asserts the refusal publishes neither identity. WAL9 read the same record as `File:write` and as an origin/key/revision triple, and now reads both admitted spellings under the URLs their owner offers them as. WGAC9's negative control asserted the absence of `File:write`, a string no record can hold any more, so it was vacuous; it now names the write identity. The third was a shard that could no longer build an npm package offline. `cli-npm-bin.test.ts` runs `scripts/build-npm.ts` with `--cached-only`, and the dnt graph's `@ts-morph/bootstrap` module bodies were in the cache only because a test that fetches them happened to run first in the same shard. Adding a test file moved the shard boundary and separated them. `scripts/deps.ts` now caches that entrypoint, which is what it exists for: a typecheck of the same graph reads dnt's types and leaves those bodies unfetched, so with a scratch `DENO_DIR` the build reports exactly CI's missing specifier after a check and completes after the entrypoint install. The prose those cases describe is corrected with them: the architecture's grant boundary is the identity a spelling runs under rather than a versioned origin, the Git Plugin preserves its released origin but no `dir-v2` alias, and WGAC10, WGAC17 and ORC6i state the admitted spellings and their URLs. --- architecture.md | 19 ++++--- packages/cli/tests/workflow-agent.test.ts | 19 ++++--- .../cli/tests/workflow-suspension.test.ts | 55 +++++++++++-------- packages/core/src/generated-xmd.ts | 4 +- packages/git/mod.ts | 7 ++- .../tests/generated-agent-component.test.ts | 8 ++- scripts/deps.ts | 6 ++ specs/executable-mdx-spec.md | 6 +- 8 files changed, 75 insertions(+), 49 deletions(-) diff --git a/architecture.md b/architecture.md index 9ff280e1e..dc79fd543 100644 --- a/architecture.md +++ b/architecture.md @@ -3611,16 +3611,17 @@ authors output around it. Local Git, Git-host, issue, process, eval and exec, native-command, credential and external-write effects are outside the class. -The versioned Dir origin is a grant boundary, not a symbol decoration. The -former `@executablemd/workflow/composition#Dir` identity authorized lexical -placement that created nothing; it does not authorize the persistent mutation. -A continuation whose retained write table names that former identity 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 identity rather than presentation. A continuation is +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 -identity to the table refuses a continuation that was admitted under the former +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 diff --git a/packages/cli/tests/workflow-agent.test.ts b/packages/cli/tests/workflow-agent.test.ts index 4f2f46905..84477ac25 100644 --- a/packages/cli/tests/workflow-agent.test.ts +++ b/packages/cli/tests/workflow-agent.test.ts @@ -722,16 +722,21 @@ describe("Tier WAL — the workflow Agent observation loop", () => { }); expect(attempt.failure).toBe(undefined); - // The standard profile admitted it: one record, naming both write - // identities and the classes the document selected. + // The standard profile admitted it: one record, naming both admitted + // spellings and the classes the document selected. const recorded = admitted(attempt.events); expect(recorded).toHaveLength(1); const policy = JSON.stringify(recorded[0]); - expect(policy).toContain("File:write"); - // The identity is a structural record, so the origin, the key and the - // revision are read as themselves rather than as one assembled spelling. - expect(policy).toContain('"origin":"@executablemd/workflow/composition"'); - expect(policy).toContain('"key":"Dir","revision":"3"'); + // What each spelling runs as comes from the declaration that owns the + // implementation, so the row holds that URL and the table that admitted + // it rather than a spelling a host assembled. + expect(policy).toContain( + '"name":"File","form":"paired","identity":"https://executable.md/components/file/write"', + ); + expect(policy).toContain( + '"name":"Dir","form":"paired","identity":"https://executable.md/components/dir/ensure"', + ); + expect(policy).toContain('"effect":"write"'); expect(policy).toContain('"allow":["write"]'); // And the change is in the run's own Workspace, where an ordinary read // beneath the fragment's own directory finds it. Anchored on the diff --git a/packages/cli/tests/workflow-suspension.test.ts b/packages/cli/tests/workflow-suspension.test.ts index 76b596201..ac610310e 100644 --- a/packages/cli/tests/workflow-suspension.test.ts +++ b/packages/cli/tests/workflow-suspension.test.ts @@ -821,6 +821,15 @@ const PROMPT_EFFECT = "agent_prompt"; // packages/core/src/agent/journal.ts const EVALUATION_EFFECT = "generated_xmd"; // packages/core/src/generated-xmd.ts const FILE_EFFECT = WORKSPACE_FILE; +/** + * What core's own declaration offers the paired `` as. + * + * Spelled here rather than imported, because a case about what a retained + * admission holds has to name the string the journal holds: reading it from the + * component the resume also reads would agree with itself after a rename. + */ +const DIR_ENSURE = "https://executable.md/components/dir/ensure"; + /** Every prompt this run's agent was actually asked. */ interface AgentCalls { readonly prompts: string[]; @@ -1508,10 +1517,10 @@ the retained note }); } - // WGAC17: `allow={["write"]}` admits the versioned paired ``, and that - // entry now authorizes persistent recursive creation. The former identity - // authorized placement that created nothing, so a run admitted under it must - // not silently receive the wider permission. + // WGAC17: `allow={["write"]}` admits the paired ``, which authorizes + // persistent recursive creation. A run admitted under a different identity + // for that spelling was granted something else, so it must not silently + // receive this permission on resume. // // Two halves, and neither is evidence alone. The control shows the current // admission really does execute its generated `` and really does resume @@ -1541,12 +1550,14 @@ the retained note const suspended = retained(path); expect(suspended.status).toBe("suspended"); - // The admission carries the versioned identity. It is a structural record - // rather than a spelling, and the version is the `revision` member — so - // that is what this reads, and what the refusal below moves. + // The admission carries the identity core's own declaration offers the + // paired spelling as, in the write table that admitted it — so that is what + // this reads, and what the refusal below moves. expect( readRecords(path).some( - (record) => record.includes(`"key":"Dir"`) && record.includes(`"revision":"3"`), + (record) => + record.includes(`"name":"Dir","form":"paired","identity":"${DIR_ENSURE}"`) && + record.includes(`"effect":"write"`), ), ).toBe(true); @@ -1616,19 +1627,18 @@ the retained note expect(counts(path)).toEqual(atSuspension); }); - it("WGAC17: a continuation retaining the former Dir identity refuses before generated execution and mutation", function* () { + it("WGAC17: a continuation retaining another Dir identity refuses before generated execution and mutation", function* () { const store = yield* useRunStore(); const { runId, path, rendered, calls } = yield* wgac17(store); - // The run becomes one admitted under the former identity. Nothing else - // changes — same source, roots and selection — so the identity is the only - // thing the resume can disagree about. - // - // The version is the `revision` member, and core's own entries state `2`, - // so `"revision":"3"` names the Dir entry and nothing else. Moving it back - // is exactly "admitted under the earlier revision", which is the grant the - // former placement-only Dir had. - rewriteRecords(path, (record) => record.replaceAll(`"revision":"3"`, `"revision":"2"`)); + // The run becomes one admitted under a different identity for the same + // spelling of the same name. Nothing else changes — same source, roots and + // selection — so the identity is the only thing the resume can disagree + // about, and it is still a URL this engine can read: the refusal is the + // grant comparison rather than an unreadable record. + rewriteRecords(path, (record) => + record.replaceAll(DIR_ENSURE, "https://executable.md/components/dir/placement"), + ); const beforeResume = counts(path); const rootsBeforeResume = workspaceRootState(path); @@ -1646,11 +1656,10 @@ the retained note expect(workspaceRootState(path)).toEqual(rootsBeforeResume); // Nothing after the wait reached the document either. expect(rendered).toEqual([]); - // And the refusal does not publish which identity moved. The identity is - // four members now, so this reads the two a refusal could carry: the origin - // that owns the implementation, and the member the rewrite actually moved. + // And the refusal does not publish which identity moved: neither the one + // the admission holds nor the one the rewrite put there. const reported = resumed.written.err.join("\n"); - expect(reported).not.toContain("@executablemd/workflow/composition"); - expect(reported).not.toContain("revision"); + expect(reported).not.toContain(DIR_ENSURE); + expect(reported).not.toContain("/components/dir/placement"); }); }); diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index c76ec62df..bd1fab52f 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -61,8 +61,8 @@ * so does this: every admitted invocation is checked against that same fact * before the component runs, and refused when it is not the form its identity * was chosen for. Nothing a handler answers reaches either read, so an admitted - * `File:read` cannot be turned into a write, or an admitted `File:write` into a - * read, however many times something is asked. + * 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 * diff --git a/packages/git/mod.ts b/packages/git/mod.ts index 4f06bb181..a04a9409d 100644 --- a/packages/git/mod.ts +++ b/packages/git/mod.ts @@ -59,9 +59,10 @@ * These components were published by `@executablemd/workflow` before this * package existed, and every journal, database row and artifact a released * build wrote names that origin. Those strings identify retained history rather - * than current source ownership, so they are preserved exactly here — including - * the `@executablemd/workflow/composition` origin and the released - * `@executablemd/workflow/composition/dir-v2#Dir` alias. + * than current source ownership, so the `@executablemd/workflow/composition` + * origin is preserved exactly here. What a generated `` runs as is not + * among them: that is stated by core's own declaration, which owns the + * implementation. */ export { default } from "./src/plugin.ts"; diff --git a/packages/workflow/tests/generated-agent-component.test.ts b/packages/workflow/tests/generated-agent-component.test.ts index 095af6996..3b9c73b97 100644 --- a/packages/workflow/tests/generated-agent-component.test.ts +++ b/packages/workflow/tests/generated-agent-component.test.ts @@ -1075,8 +1075,12 @@ describe("Tier WGAC — the classes an authored element may select", () => { const recorded = admissions(attempt.events); expect(policyOf(recorded[0]!)?.allow).toEqual(["read"]); // And the write table is not in the retained policy at all, so a run that - // changed it has not changed what this admission was granted under. - expect(JSON.stringify(policyOf(recorded[0]!))).not.toContain("File:write"); + // changed it has not changed what this admission was granted under. Read + // as the identity the write spelling runs under, which is what a row in + // that table would hold. + expect(JSON.stringify(policyOf(recorded[0]!))).not.toContain( + "https://executable.md/components/file/write", + ); }); }); }); diff --git a/scripts/deps.ts b/scripts/deps.ts index 93fd94df4..e3fa3dfdc 100644 --- a/scripts/deps.ts +++ b/scripts/deps.ts @@ -46,6 +46,12 @@ const ENTRYPOINTS = [ "packages/cli/src/compiled.ts", "scripts/compile.ts", "scripts/verify-clean.ts", + // The npm build, which `scripts/tests/cli-npm-bin.test.ts` runs + // `--cached-only`. A typecheck of this graph is not enough: it reads dnt's + // types and leaves `@ts-morph/bootstrap`'s module bodies unfetched, so the + // build found them only when a test that fetches them happened to run first + // in the same shard. + "scripts/build-npm.ts", ]; function* deno(args: string[]): Operation { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index cff5dc977..11dbea17f 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -13176,14 +13176,14 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §8.4. | 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 forms, in the order the profile states them — core's paired `File:write`, paired `@executablemd/workflow/composition/dir-v2#Dir`, then core's self-closing `File.Delete` — and a `` and a self-closing `` are each refused under the same selection with no effect | +| 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 | | 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 | -| WGAC17 | Directory mutation permission | `allow={["write"]}` admits the versioned paired `` and intentionally authorizes its persistent recursive creation; a continuation retaining the former `@executablemd/workflow/composition#Dir` refuses before generated execution and creates nothing, while an unchanged current admission replays without a second ensure | +| 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 @@ -13987,7 +13987,7 @@ user's own `~/.xmd/repositories`. | ORC6f | Workflow atomic ensure | Recursive creation, one durable `workspace_file` success and the resulting Workspace root commit together before content; the root retained by the effect is the authoritative current root | | ORC6g | Workflow refusal rollback | A target or intermediate non-directory rolls back its mutation savepoint, publishes a sanitized refusal against the unchanged root, creates no partial parent and begins no content | | ORC6h | Workflow replay and cancellation | Completed replay restores the recorded success and retained root without ensuring again; cancellation before commit publishes neither, while later content cancellation preserves the committed directory and restores cwd | -| ORC6i | Generated write permission | `allow={["write"]}` admits paired `@executablemd/workflow/composition/dir-v2#Dir` and authorizes persistent recursive creation; a retained former `@executablemd/workflow/composition#Dir` entry refuses before generated execution and mutation | +| ORC6i | Generated write permission | `allow={["write"]}` admits the paired `` under `https://executable.md/components/dir/ensure` and authorizes persistent recursive creation; a retained entry naming another identity for that spelling refuses before generated execution and mutation | | ORC7 | Session placement | A Session launched in a managed Worktree receives that Git root and a distinct worktree session key; `.git`-file discovery remains the boundary | | ORC8 | Persistent lifecycle | Managed paths, metadata and working files survive normal completion, authored failure and cancellation; no teardown Git or delete command occurs | | ORC9 | Compatible reuse | A second invocation with the same immutable request reuses the same path and preserves a moved branch and a later commit while revalidating owner, origin, object format and creation commit |