diff --git a/src/capability-language/parser.ts b/src/capability-language/parser.ts index 029d843..102b950 100644 --- a/src/capability-language/parser.ts +++ b/src/capability-language/parser.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { BaseErrorListener, CharStream, @@ -1712,7 +1713,8 @@ const lowerPackage = ( const lowerState = (state: LoweringState, context: StateDeclContext): StateSlotDefinition => { const atomName = identifier(context.identifier(1)); const atomId = - requireSymbol(state, state.atoms, atomName, context, "atom") ?? capabilityId.atom(`unresolved:${atomName}`); + (atomName === "Self" ? state.types.self : requireSymbol(state, state.atoms, atomName, context, "atom")) ?? + capabilityId.atom(`unresolved:${atomName}`); const policy = context.storagePolicy(); const defaultContext = context.jsonLiteral(); let defaultValue: unknown; @@ -1932,7 +1934,18 @@ const lowerBoundDependencies = ( : []; } const atomName = identifier(entry.identifier(1)); - const atomId = requireSymbol(state, state.atoms, atomName, entry, "atom"); + const parameter = state.types.parameters.get(atomName); + const argument = parameter && state.types.bindings.get(parameter.id); + const atomId = + atomName === "Self" + ? state.types.self + : argument?.kind === "object" && argument.target.kind === "atom" + ? argument.target.atomId + : parameter + ? undefined + : requireSymbol(state, state.atoms, atomName, entry, "atom"); + if (!atomId && (parameter || atomName === "Self")) + loweringIssue(state, entry, "invalid-creation-target", "Creation requires a concrete atom argument"); if (entry.CREATION()) return atomId ? [ @@ -2031,19 +2044,90 @@ const lowerConformanceRule = (state: LoweringState, context: ConformanceDeclCont const targetDefinition = state.types.definitions.get(target.definitionId)!; const applications = [source, target]; const members = targetDefinition.template?.members ?? targetDefinition.members; + const privateStorage = new Map(); + const privateIds = new Set(); for (const item of context.conformanceItem()) { + const declaration = item.attachmentDecl(); + if (!declaration) continue; + const stateDeclaration = declaration.stateDecl(); + const edgeDeclaration = declaration.edgeDecl(); + const name = identifier(stateDeclaration ? stateDeclaration.identifier(0) : edgeDeclaration!.identifier()); + const authoredId = stringValue((stateDeclaration ?? edgeDeclaration)!.stringLiteral()); + if (privateStorage.has(name)) + loweringIssue(state, declaration, "duplicate-symbol", `Duplicate private attachment ${name}`); + if (privateIds.has(authoredId)) + loweringIssue(state, declaration, "duplicate-attachment", `Duplicate private attachment ID ${authoredId}`); + privateStorage.set(name, stateDeclaration ? "state" : "edge"); + privateIds.add(authoredId); + if (stateDeclaration && identifier(stateDeclaration.identifier(1)) !== "Self") + loweringIssue(state, stateDeclaration, "invalid-rule-storage", "Rule private state must attach to Self"); + if (stateDeclaration) { + try { + state.types.expression(stateDeclaration.valueType()); + if (stateDeclaration.storagePolicy().valueType()) + state.types.expression(stateDeclaration.storagePolicy().valueType()!); + } catch (error) { + if (error instanceof GenericTypeError) loweringIssue(state, stateDeclaration, error.code, error.message); + else throw error; + } + } if ( - item.attachmentDecl() || - item.initializationDecl() || - item.relationshipMaterializationDecl() || - item.stateFieldBindingDecl() - ) { - loweringIssue( - state, - item, - "unsupported-rule-storage", - "Rules support operation and module bindings, not private storage, initialization or materialization", - ); + edgeDeclaration && + !edgeDeclaration + .edgeEndpoint() + .some((endpoint) => identifier(endpoint.targetConstraint().identifier()) === "Self") + ) + loweringIssue(state, edgeDeclaration, "invalid-rule-storage", "Rule private edge must have a Self endpoint"); + for (const endpoint of edgeDeclaration?.edgeEndpoint() ?? []) { + try { + state.types.targetExpression(endpoint.targetConstraint()); + } catch (error) { + if (error instanceof GenericTypeError) loweringIssue(state, endpoint, error.code, error.message); + else throw error; + } + } + } + if (context.conformanceItem().filter((item) => item.initializationDecl()).length > 1) + loweringIssue(state, context, "duplicate-initialization", "Declare one initialization mapping per rule owner"); + for (const item of context.conformanceItem()) { + const field = item.stateFieldBindingDecl(); + if (field) { + const member = members.find((entry) => entry.displayName === identifier(field.identifier(0))); + if (member?.kind !== "value" || privateStorage.get(identifier(field.identifier(1))) !== "state") + loweringIssue( + state, + field, + "invalid-state-field-binding", + "Field shorthand requires a value member and rule-private state", + ); + } + const initialization = item.initializationDecl(); + for (const entry of initialization?.initializationField() ?? []) { + const kind = privateStorage.get(identifier(entry.identifier(1))); + if (!kind || (entry.STATE() && kind !== "state") || (entry.EDGE() && kind !== "edge")) + loweringIssue( + state, + entry, + "invalid-initialization", + "Initialization must name matching rule-private storage", + ); + if (kind === "edge" && entry.EDGE()) { + const edge = context + .conformanceItem() + .flatMap((candidate) => candidate.attachmentDecl()?.edgeDecl() ?? []) + .find((candidate) => identifier(candidate.identifier()) === identifier(entry.identifier(1))); + if ( + !edge + ?.edgeEndpoint() + .some((endpoint) => identifier(endpoint.identifier()) === identifier(entry.identifier(2))) + ) + loweringIssue(state, entry, "unknown-symbol", "Unknown rule-private edge projection"); + } + } + } + for (const item of context.conformanceItem()) { + if (item.relationshipMaterializationDecl()) { + loweringIssue(state, item, "unsupported-rule-storage", "Rule relationship materialization is not supported"); continue; } const module = item.moduleBindingDecl(); @@ -2095,6 +2179,28 @@ const lowerConformanceRule = (state: LoweringState, context: ConformanceDeclCont ); } } + } else if (provider.STATE() || provider.EDGE()) { + const name = identifier(provider.identifier(0)); + const expected = provider.STATE() ? "state" : "edge"; + if (privateStorage.get(name) !== expected) + loweringIssue( + state, + provider, + "invalid-rule-storage-binding", + `Rule provider must name private ${expected} ${name}`, + ); + if (provider.EDGE()) { + const edge = context + .conformanceItem() + .flatMap((candidate) => candidate.attachmentDecl()?.edgeDecl() ?? []) + .find((candidate) => identifier(candidate.identifier()) === name); + if ( + !edge + ?.edgeEndpoint() + .some((endpoint) => identifier(endpoint.identifier()) === identifier(provider.identifier(1))) + ) + loweringIssue(state, provider, "unknown-symbol", "Unknown rule-private edge projection"); + } } else { loweringIssue( state, @@ -2123,8 +2229,13 @@ const lowerConformanceBody = ( context: ConformanceDeclContext, privateAttachments: PersistentAttachment[], implementingAtom?: AtomId, + ruleApplicationId?: string, ): WorkspaceRevision["conformances"][number] | undefined => { - const state = { ...parent, interfaces: new Map(parent.interfaces) }; + const state = { + ...parent, + interfaces: new Map(parent.interfaces), + attachments: ruleApplicationId ? new Map(parent.attachments) : parent.attachments, + }; const atomName = identifier(context.identifier(0)); const interfaceName = identifier(context.identifier(1)); const atomId = implementingAtom ?? requireSymbol(state, state.atoms, atomName, context, "atom"); @@ -2132,6 +2243,36 @@ const lowerConformanceBody = ( if (!atomId || !interfaceSymbol) { return undefined; } + if (ruleApplicationId) { + privateAttachments = []; + for (const item of context.conformanceItem()) { + const declaration = item.attachmentDecl(); + if (!declaration) continue; + const attachment = lowerAttachment(state, declaration); + if (!attachment) continue; + const authoredId = attachment.id; + const scopedId = createHash("sha256") + .update(JSON.stringify(["quixos-rule-attachment-v1", ruleApplicationId, authoredId])) + .digest("hex"); + if (attachment.kind === "state") { + attachment.id = capabilityId.slot(`rule:sha256:${scopedId}`); + } else { + attachment.id = capabilityId.edgeType(`rule:sha256:${scopedId}`); + for (const endpoint of attachment.endpoints) { + const projectionHash = createHash("sha256") + .update(JSON.stringify(["quixos-rule-projection-v1", ruleApplicationId, authoredId, endpoint.projectionId])) + .digest("hex"); + endpoint.projectionId = capabilityId.edgeProjection(`rule:sha256:${projectionHash}`); + } + } + privateAttachments.push(attachment); + // A rule application owns a lexical scope. A same-named attachment in + // another owner must neither prevent this declaration nor receive bindings. + const name = identifier(declaration.stateDecl()?.identifier(0) ?? declaration.edgeDecl()!.identifier()); + state.attachments.delete(name); + registerAttachment(state, declaration, attachment); + } + } const originalSymbol = interfaceSymbol; if (context.typeArguments() || interfaceSymbol.definition?.template) { const previousSelf = state.types.self; @@ -2654,7 +2795,7 @@ const lowerWorkspace = ( const declaration = rules.get(application.rule)!; const result = atGenericSource(declaration, () => withConformanceTypes(state, application.atomId, application.rule.parameters, application.bindings, () => - lowerConformanceBody(state, declaration, [], application.atomId), + lowerConformanceBody(state, declaration, [], application.atomId, application.id), ), ); if (result) { diff --git a/test/conformance-rules.test.ts b/test/conformance-rules.test.ts index b4208dd..dfda342 100644 --- a/test/conformance-rules.test.ts +++ b/test/conformance-rules.test.ts @@ -75,12 +75,17 @@ test("rule conflicts identify competing implementations", () => { assert.match(JSON.stringify(result.diagnostics), /duplicate-conformance/); }); -test("rules validate unused bodies and reject storage and ambiguous names", () => { +test("rules validate unused bodies and ambiguous names", () => { const compile = fixture(nameResources); for (const [body, code] of [ ["conform Person as HasName { }", "missing-rule-id"], ['conform Person as HasName id "r" {bind name.get to interface Person.missing.get;}', "unknown-operation"], - ['conform Person as HasName id "r" { initialize {}; }', "unsupported-rule-storage"], + ['conform Person as HasName id "r" { initialize { content to state Missing; }; }', "invalid-initialization"], + ['conform Person as HasName id "r" { bind name to state Missing; }', "invalid-state-field-binding"], + [ + 'conform Person as HasName id "r" {private state Bad id "bad" on Self : Missing policy register;}', + "unknown-type", + ], ['atom Person id "p";' + namedRule, "ambiguous-conformance-source"], [namedRule + namedRule, "duplicate-rule-id"], ]) { @@ -90,6 +95,125 @@ test("rules validate unused bodies and reject storage and ambiguous names", () = } }); +test("rule storage is scoped per application, including repeated specializations on one atom", async () => { + const compile = fixture([ + 'interface Source id "source" revision "source@1" {}', + 'interface Stored id "stored" revision "stored@1" {value content id "value" : optional {get id "get";set id "set";}}', + ]); + const rule = `conform Source as Stored id "store-rule" { + private state Value id "stored:value" on Self : optional policy register default null; + bind content to state Value; + }`; + const atoms = `atom A id "a"; atom B id "b"; + conform A as Source id "a-string" {} + conform A as Source id "a-number" {} + conform B as Source id "b-string" {}`; + const first = ok(compile(rule + atoms)).workspace; + const second = ok(compile(atoms + rule)).workspace; + const storage = (workspace: typeof first) => + workspace.conformances + .filter((c) => c.ruleOrigin) + .map((c) => [c.id, c.privateAttachments, c.operationBindings]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))); + assert.deepEqual(storage(first), storage(second)); + const owners = first.conformances.filter((c) => c.ruleOrigin); + assert.equal(owners.length, 3); + assert.equal(new Set(owners.map((c) => c.privateAttachments[0]!.id)).size, 3); + for (const owner of owners) { + const state = owner.privateAttachments[0]!; + assert.equal(state.kind, "state"); + if (state.kind !== "state") throw Error("state expected"); + assert.equal(state.attachedTo, owner.atomId); + assert(owner.operationBindings.every((b) => b.binding.kind === "state" && b.binding.slotId === state.id)); + } + const { storageContracts, storageChangeRequiresMigration } = await import("../src/capability-model/evolution.js"); + const before = storageContracts(first); + const changed = ok( + compile( + atoms.replace('conform A as Source id "a-number" {}', "") + + rule.replace("default null", 'default "new default"'), + ), + ).workspace; + // Use the string specialization for default evolution; all nominal identities + // remain stable, and changing a default must not rewrite existing state. + const oldSlot = before.find((s) => s.id === owners.find((c) => c.atomId === "b")!.privateAttachments[0]!.id)!; + const newSlot = storageContracts(changed).find((s) => s.id === oldSlot.id)!; + assert.equal(storageChangeRequiresMigration(oldSlot, newSlot, new Set(["a", "b"])), false); + assert.equal(storageChangeRequiresMigration(oldSlot, undefined, new Set(["a", "b"])), true); +}); + +test("rule attachments shadow outside names without leaking into another owner", () => { + const rule = `conform Person as HasName id "r" { + private state NameLiving id "rule:name" on Self : string policy register default "rule"; + bind name to state NameLiving; + }`; + const workspace = ok(fixture(nameResources)(rule + person("Living"))).workspace; + const original = workspace.conformances.find((c) => c.interfaceRevisionId === "person@1")!; + const derived = workspace.conformances.find((c) => c.interfaceRevisionId === "name@1")!; + assert.notEqual(original.privateAttachments[0]!.id, derived.privateAttachments[0]!.id); + assert( + derived.operationBindings.every( + (b) => b.binding.kind === "state" && b.binding.slotId === derived.privateAttachments[0]!.id, + ), + ); + assert( + original.operationBindings.every( + (b) => b.binding.kind === "state" && b.binding.slotId === original.privateAttachments[0]!.id, + ), + ); +}); + +test("rule private edges substitute Self and captured endpoints with stable projection IDs", () => { + const compile = fixture([ + 'interface Linkable id "linkable" revision "linkable@1" {}', + 'interface Links id "links" revision "links@1" {relation items id "items" : many object T {resolve id "resolve";}}', + ]); + const source = `atom A id "a"; atom B id "b"; atom Target id "target"; + conform A as Linkable id "a-link" {} conform B as Linkable id "b-link" {} + conform Linkable as Links id "links" { + private edge Items id "items" { + object Self projection items id "forward" many; + object T projection owners id "inverse" many; + } + bind items.resolve to edge Items.items.resolve; + }`; + const workspace = ok(compile(source)).workspace; + const edges = workspace.conformances.filter((c) => c.ruleOrigin).map((c) => c.privateAttachments[0]!); + assert.equal(edges.length, 2); + assert.notEqual(edges[0]!.id, edges[1]!.id); + const projections = edges.flatMap((edge) => { + assert.equal(edge.kind, "edge"); + if (edge.kind !== "edge") throw Error("edge expected"); + assert.deepEqual(edge.endpoints[1].constraint, { kind: "atom", atomId: "target" }); + return edge.endpoints.map((e) => e.projectionId); + }); + assert.equal(new Set(projections).size, 4); +}); + +test("rule creation ports close captured atom parameters and retain initialization checking", () => { + const compile = fixture([ + 'interface Factory id "factory" revision "factory@1" {operation create id "create" : unit -> ref {call id "call" execution transaction targets [browser,server];}}', + 'interface Creates id "creates" revision "creates@1" {}', + 'package Runtime id "runtime" revision "runtime@1" {operation create id "create" : unit -> ref mode call execution transaction targets [browser,server] receiver any requires {creation result id "result" : T input unit;};}', + ]); + const rule = `conform Creates as Factory id "factory-rule" { + bind create.call to package Runtime.create with {result to creation T with {};}; + }`; + const result = ok( + compile(`atom Subject id "subject"; atom Product id "product"; + conform Subject as Creates id "creates-product" {} ${rule}`), + ); + const owner = result.workspace.conformances.find((c) => c.ruleOrigin)!; + const binding = owner.operationBindings[0]!.binding; + assert.equal(binding.kind, "package"); + if (binding.kind !== "package") throw Error("package expected"); + assert.deepEqual(binding.dependencies[0]!.binding, { kind: "creation", atomId: "product", owners: [] }); + const invalid = compile(`atom Subject id "subject"; + conform Subject as Creates> id "creates-interface" {} ${rule}`); + assert.equal(invalid.ok, false); + assert.match(JSON.stringify(invalid.diagnostics), /concrete atom/); +}); + const genericResources = [ 'interface Placeable id "placeable" revision "placeable@1" {}', 'interface Eligible id "eligible" revision "eligible@1" {}', diff --git a/test/creation-contracts.test.ts b/test/creation-contracts.test.ts index 88f07de..5373722 100644 --- a/test/creation-contracts.test.ts +++ b/test/creation-contracts.test.ts @@ -87,6 +87,27 @@ test("two factories initialize two independent state owners without constructors ]); } }); + +test("rule-owned state uses the same checked initialization and creation obligations", () => { + const source = text + .replace('conform Note as Data id "owner:data"', 'conform Tagged as Data id "rule:data"') + .replace("on Note : string", "on Self : string"); + const result = compile(source); + assert.ok(result.ok, JSON.stringify(result.diagnostics)); + const owner = result.workspace.conformances.find((c) => c.ruleOrigin?.ruleId === "rule:data")!; + assert.equal(owner.initialization?.[0]?.binding.kind, "state"); + assert(owner.privateAttachments[0]!.id.startsWith("rule:sha256:")); + for (const [invalid, diagnostic] of [ + [source.replaceAll("Data from input.data;", ""), "Required state"], + [source.replace("initialize { title to state Title; };", ""), "no declared initialization mapping"], + [source.replace("title to state Title;", "title to state Tags;"), "rule-private storage"], + [source.replaceAll("Data from input.data;", "Data from input.tags;"), "does not match"], + ]) { + const failure = compile(invalid!); + assert.equal(failure.ok, false); + assert.match(JSON.stringify(failure.diagnostics), new RegExp(diagnostic!)); + } +}); test("creation rejects missing seeds, wrong input shapes, and private initialization laundering", () => { for (const [edit, message] of [ [text.replaceAll("Tagged from input.tags;", ""), "Required state tags-state"],