Changelog
All notable changes to SharedOS are recorded here. The packages share one
version and are published together under npm's next dist-tag.
SharedOS is a 0.x prerelease: contracts may change between prereleases, and
each entry calls out what a host has to update.
0.1.0-alpha.5
Changed — breaking
-
A turn's tool catalogue is resolved once, and every call in the turn is answered from it. Three things already said so:
OpenToolBridgecomputes its catalogue once, because "a catalogue that could change betweentools/listandtools/callwould makecatalogHasha claim about a moment rather than about the turn"; the MCP server answersinitializewithcapabilities.tools.listChanged: false, a promise on the wire to every client; and an execution token bindscatalogHashinto its claims, so a stale sandbox cannot reconnect and call tools it was never shown. The kernel did not — it re-derived the effective registry on every operation. A static registry cannot differ between two derivations, but aContextToolProvidercan: it is an async port called with the turn's context, and the MCP server or host configuration behind it is live, so a turn's second call could be decided against a definition its listing never published. The resolved registry is now held for the turn on the authority lease ADR 0010 already opens, keyed by the sameturnAuthorityKey; an operation with no lease resolves its own, which is a turn of one operation (ADR 0026,docs/adr/0026-catalogue-resolved-once-per-turn.md).What the turn holds is the unfiltered registry, not a decision.
canDiscoverand the invocation check still run per operation against authority resolved atcontext.now, so a grant that expires part-way through a turn still refuses part-way through it (ADR 0016), andupdateToolNamespacesstill takes effect within the turn it is called in.What a host has to update. A
ContextToolProvideris called once per turn rather than once per operation, so a provider written to return different tools as a turn proceeds no longer changes what that turn is answered from — that window is the one this closes. A provider that branches onenabledToolNamespacesreads the value the turn resolved with rather than the current operation's, because the lease key deliberately excludes that field;ContextToolProvider's own documentation now states both. A provider that is slow, remote or rate-limited is called once a turn instead of once a call. A provider returning freshly constructedToolHandlers gets one set per turn rather than one per operation, so a handler carrying state between its ownresolveRequirementandinvokemust key that state on the call it was given and not hold it on the handler: the turn's concurrent calls now share the instance. SharedOS's own message-request handler was written the other way and has been corrected.A new conformance case grades it. In
catalogue-moved-mid-turna provider publishes a tool for the turn's listing and then moves its declared capability onto an action no grant carries. The call made after the move succeeds in all six columns with this decision and fails in all six without it, and the same tool aimed outside the granted tree is still deniedno_matching_grant— what the turn holds is the catalogue, not the decision. The manifest goes to 32 rows and 165 passing cells, and the case-set, world-set and prompt-set hashes move with it.Resolving the catalogue reads 14.3 µs against 1.33 ms, under 1% of a mediated call against 45%, and one whole call 968 µs against 2.44 ms.
Added
-
The shipped runtimes tell the model where it may operate.
RuntimeVisibleContext.reachwas handed to every runtime and read by none, so a model still searched from the root and collected denials, which is the problem the field was added to end.describeReach, exported from@aicoo/sharedos-runtime, renders the result as words: each entry as its namespace, its path as the JSON array apathargument takes, and whether it covers what lies beneath; an empty reach as "nowhere"; anunavailableone as exactly that, with its reason code, so "unknown" never reads as "nothing" (ADR 0021).ModelDriversends it as a system message ahead of the prompt, andcreateMcpHarnessRuntimehands it to the harness as the MCP server's initialize instructions, with the host's owninstructionsstring placed before it. Both are overridable:ModelDriverOptions.instructionsis a function of the turn request,McpHarnessRuntimeOptions.instructionsnow also accepts one, and returningundefinedsends nothing. A host writing its own driver can calldescribeReachand say the same thing the same way.What a host has to update. A model transcript or a test that pinned the first message to the prompt now sees a system message before it. A harness that does not surface MCP instructions shows its model the catalogue alone, which is what it saw before.
-
Every
AuditEventcarries anid. A record's identity was its content: the kernel stampsatfrom the turn'sAccessContext.now, a bareauthorizecarries nooperationId, and nothing else on the record distinguishes one emission from the next, so the same question asked twice in one turn produced two byte-identical records. A sink that deduplicated on a content hash -- the natural idempotency key when the record offers no other -- kept one and dropped the rest; an end-to-end run over 1,444 host decision records lost more than a quarter of them that way. The id is minted when the event is made, a random UUID by default, andSharedOSKernelOptions.createAuditIdsupplies a deterministic one for a replayed fixture.auditEventandautoDecisionAuditEventtake the same factory as a trailing argument.A sink that deduplicates keys on
id. A content hash is still the right fallback for a record written before this field existed, and only for one.atis unchanged and still the turn's instant: two records with oneatare one turn's work, not one record, and their order within the turn is the order the sink received them. -
A model client can name its reasoning mode, and the turn records it.
OpenAiCompatibleModelClientOptions.thinkingsends DeepSeek'sthinkingrequest field,enabledordisabled. It is opt-in: the field is not part of the OpenAI wire shape and a provider that does not know it rejects the request, so nothing is sent until a host asks. A mode is a configuration rather than a model, soModelClientgains an optionalsettingsobject — what the client sends beyond the model name — andModelDriverrecords it on every turn asmodelSettingsbesidemodel,modelProviderandrequestedModel. A host with its ownModelClientmay leavesettingsundefined and sees no change. -
A turn records what the seat was told, and the manifest names it per column. The catalogue a model was served has carried a hash on every record since the MCP bridge existed; the prompt it was given carried none, so rewording it moved nothing on disk, and a cell that moved between two runs could not be told apart from the model changing its mind. One sentence about what the tool-call channel carries took five
not exercisedcells to none on the same model, and neither run said it had asked a different question. Both shipped runtimes now computepromptHashbefore the seat is sent anything, over the same two texts in the same shape —{instructions, prompt}in canonical JSON,instructionsbeing the reach asModelDriver's system message or ascreateMcpHarnessRuntime's initialize instructions,nullwhen the host sends none — and it lands inSystemIdentity.promptHashbesidecatalogHash. The conformance manifest folds each column's per-turn hashes, in row order, intopromptSetHashon the column entry, so the committed manifest pins the question the Standard column is asked andconformance:checkmoves when the wording does. Columns that hand the seat no text — the adversary, a vendor column driven by written frames — carry none. The live scripts print it beside the model. It covers what SharedOS said: a CLI's own system prompt is added on the far side of the wire and is not claimed.What a host has to update. Nothing.
promptHashandpromptSetHashare optional additions. A host comparing two live runs of one column should now holdpromptSetHashequal along with the case-set and world-set hashes before reading a moved cell as the model's choice. -
tool.invokednames the catalogue its turn was served.catalogHashwas recorded ontool.catalog.listedand on no other event, so "every call in this turn was answered against the catalogue that was listed" was a claim in a source comment rather than something a reader of the trail could check. The kernel now holds the listing's hash beside the resolution it identifies and puts it intool.invoked's metadata, on the refused calls as well as the served ones. A turn that never listed leaves the field off rather than inventing one, so its absence reads as "no catalogue was published to this turn" and never as "a different one".A reader joins the two events on the value. Time order was the only inference available before, and is no longer needed.
Changed
-
A string-carrying adapter reads tool arguments without the recursive schema. Codex, DeepSeek and the native harness's driver each carry a call's arguments as a JSON-encoded string, and
parseToolArgumentsparsed it and then ran the result throughJsonObjectSchema, which tries every branch of the value union at every node. That pass was most of what those three spent per call: 118–130 µs against the 13 µs of Pi and Claude Code, whose frames carry the object already parsed. The parsed value is now read by a walk that checks only where the parser's output and the schema's verdict part — a number literal that overflowed to an infinity is refused, a"__proto__"key is dropped at every depth — and hands the value back as it was otherwise. The verdict and the value are the schema's, blob for blob, and a test holds them to it. Indocs/conformance/systems-cost.mdthe translate rows read Standard 11.7 µs, Codex 4.97 µs and DeepSeek 15 µs on the reference machine, from 128, 118 and 130. The kernel's ownJsonObjectSchemapass over every call's arguments is unchanged. -
The kernel reads a tool parser's return value without the recursive schema. Every registered tool's
parseArgumentsruns before authorization, and what it returns is the call the kernel carries forward — intoresolveRequirement, the decision,invokeand the audit record. The kernel held that value toJsonObjectSchemaso a parser that handed back a Date, a Map, anundefinedfield or a non-finite number was refused asinvalid_tool_argumentsand the call stayed a plain JSON object. It still is, by a walk that gives the schema's verdict rule for rule — finite numbers only;undefined, bigints, symbols and functions refused wherever they sit; Dates, Maps, Sets and thenables refused where an object was expected; every other object read as a record of the keysfor...inreaches, into a fresh plain object with a"__proto__"key dropped at every depth — and a test holds the two to each other over forty-nine shapes. The refusal code, its place before authorization and thestructuredClonethat follows are unchanged; the schema's own copy of every container, made before the clone made another, is what goes. On the reference machine the check and the clone together read 6.4 µs per call, from 155. -
The live conformance prompt says what the model seat's channel does, and stops labelling the arguments. A live model in the Standard column skipped every call to a name absent from its tool list and reported it as refused without making it, because it believed the function-calling channel carries only defined names; the hidden-tool, rollback-unavailable, broker-ungranted, escalation and record-completeness rows were
not exercisedin most runs without the kernel being asked.MovePromptOptions.unknownNamesReachKerneladds one sentence saying the channel accepts any name and the kernel refuses it there; the two model columns pass it, and no other column does, because an MCP client's own router refuses an unlisted name before it is sent. Each call now reads "with {...}" rather than "with arguments: {...}": the label led the model to nest every call's arguments under anargumentskey, which the kernel fails asinvalid_tool_arguments, and a control failed that way is a row that proved nothing. Over the full set on deepseek-v4-flash, twice each: 5 and 4not exercisedbefore, 0 and 1 after.promptSetHash, now added above, covers the prompt, and the Standard column's entry in the committed manifest pins this wording. -
pnpm conformance:nativeruns its model column with reasoning off. The column measures whether a call reaches the kernel and how the kernel answers, not how the model deliberates, and with reasoning on deepseek-v4-flash spent up to its whole 4,096-token reply budget on the test prompt before its first call, failing the turn undermodel_output_truncatedwith nothing attempted. The default is nowthinking: disabled;SHAREDOS_MODEL_THINKING=enabledrestores reasoning andprovidersends no such field. The mode is in the column's label —Standard (deepseek-v4-flash, thinking off)— in the availability entry, and in every turn'smodelSettings, so a run in one mode is never read as a run in the other. A full run takes two minutes instead of four to five. -
The kernel copies its tool registry instead of rebuilding it per call.
listTools,listToolNamespaces,updateToolNamespacesandinvokeTooleach resolve the effective catalogue, and resolving it meant constructing a freshToolRegistryand re-registering every static handler: aToolDefinitionSchema.safeParse, aJSON.parse(JSON.stringify(...))clone and a deep freeze per tool, over definitionsregisterhad already parsed, cloned and frozen once, arriving each time at a value guaranteed equal to the one it started from. The price grew with the catalogue, so when the conformance world moved to the shipped file vocabulary — five published tools to seventeen — catalogue resolution went with it, to 79% of one mediated call.ToolRegistrygainscopy(), which shares the registered entries, and the kernel resolves from that. Indocs/conformance/systems-cost.mdresolving the catalogue reads 1.33 ms against 6.99, and one whole mediated call 2.44 ms against 8.23, on the reference machine.What the catalogue contains does not change,
catalogHashwith it, and neither do the two properties the rebuild was carrying: aContextToolProvideroffering a name a host already registered still fails closed on the duplicate, and a registry a host passes asoptions.toolsis still never mutated by the call that adds context-supplied tools beside it. A third property the rebuild had been carrying silently is now explicit:registerfreezes the entry, not only the definition inside it, because two registries sharing a mutable entry would let an assignment through one of them change what the other serves. Host code that wrote to a registered handler was reaching into kernel state and now raises aTypeError. The message-request tool is still constructed per call, because its handler holds the envelope it prepared betweenresolveRequirementandinvokeand one instance per call is what keeps that state from being shared by concurrent calls. -
Registering a tool reads its JSON Schema without the recursive value schema.
ToolRegistry.registerheld every definition toToolDefinitionSchema, whoseinputSchema,outputSchemaandmetadataareJsonObjectSchema— a union tried at every branch of every node. On the shippedmessages.requestdefinition, whoseinputSchemais a nestedoneOf, deciding that its JSON Schema is JSON cost 1244 µs of a 1302 µs parse: 93% of the 1340 µs the registration took. Those three fields are now read by the same single-pass walk the kernel already uses for a tool parser's return value, and the schema is shown an empty object where each one was, so it still decides which fields are required, which are optional and which are unknown, and it still decides every other field itself. The walk's verdict is held toJsonObjectSchema's over forty-nine shapes, andregister's whole result to what it produced before over thirty more in each of the three fields.The walk parses a copy of the definition, so it is taken only where the copy cannot say something different from the original: a plain-prototype object whose own properties are all enumerable data properties, with
Object.prototypeunpolluted. A definition that inherits a field, hides one behind a non-enumerable descriptor or a getter, or arrives carried by an array or aDate, goes to the schema exactly as it was passed — the path this replaced, unchanged — because a spread would drop whatz.objectreads through the prototype chain and what.strict()collects withfor...in. Whether a definition is accepted, and what it holds once registered, is therefore the same as before for every definition, not only for the common ones.Registering
messages.requestreads 70 µs against 1340, and the conformance world's nineteen definitions 723 µs against 7334. Since a catalogue is resolved once per turn this is a per-turn cost, paid where the turn's catalogue is derived, sodocs/conformance/systems-cost.md— every figure in which is a span inside one mediated call — is unchanged and cannot show it.Nothing a definition holds changes. The JSON round trip that follows the schema is kept over the whole definition rather than left to the copy the walk makes, because it is the only step that normalizes
-0to0, and a registration that quietly stopped doing that would hold a different value.
0.1.0-alpha.4
Changed — breaking
-
tool.catalog.listedno longer lists tool names. ItsmetadatacarriedvisibleTools, one name per tool the listing returned. It is gone. The event now records what the listing was computed from and what it came to, as identifiers and a count:catalogHash, the catalogue the caller was shown, computed exactly aslistPublishedToolscomputes it so an execution's manifest and the audit record match on one value;enabledNamespaces, the caller's own filter;hostPolicyVersion, the version the turn'sPolicySourcestated, when one loaded; andwithheldCount.authorityHashstays at the top level.failClosed: truenow also appears on asucceededlisting when at least one tool was withheld by an outage rather than by a decision, which the count alone could not say.A consumer reading
visibleToolsfrom audit rebuilds from the identifiers instead. The names a listing returned are whatlistPublishedToolsreturned, andcatalogHashsays whether two listings returned the same ones; an attempted call on a withheld tool is still recorded ontool.invokedwith its owncause. ADR 0023 records the shape. -
requiredCapabilityon a denial is nowrequiredAuthority, andEscalation.requestis nowrequestedAuthority. 0.1.0-alpha.3 shippedAuthorizationDecision.requiredCapability, theCapabilityRequestano_matching_grantdenial describes, andEscalation.request, withEscalationOptions.requestcarrying one intoSharedOSKernel.recordEscalation. Both are renamed: one concept in two roles, each ending in the noun this repository uses for what grants confer — and notrequiredCapability, becauseToolDefinition.requiredCapabilityalready means something else in the same package, a bareCapabilityRequirementrather than the fullerCapabilityRequestthese carry, and it is the field a reader meets first. What they carry is unchanged: a description and not an offer, minted from the trusted context, granting nothing.AuditEventgains a top-levelrequestedAuthority, written onescalation.requestedwhen the escalation named a capability. ADR 0019 is rewritten around the new names.A host reading
requiredCapabilityoff a decision, or passingrequesttorecordEscalation, renames both; a consumer of audit events admitsrequestedAuthority. The schemas are.strict(), so nothing here is additive for a reader: a consumer built against alpha.3 rejectsrequestedAuthorityon anExecutionResult's escalation as an unknown key rather than ignoring it, and surfaces a malformed-response error with nothing to explain it. Upgrade every consumer of these types in step with the host that writes them.ProtocolVersionSchemais deliberately left at"1": it is one literal shared byExecutionRequest,ExecutionEvent,ExecutionResult,MessageEnvelope, andRuntimeManifest, so moving it would re-stamp four objects that did not change in order to signal one renamed field on a fifth. The bump rides the next release with its own reason to move;docs/open-items.mdholds the row, and ADR 0019 records the decision and the failure mode it accepts. -
A host ceiling is consulted per candidate grant, before consumption, and its audit flag moves to
authority.resolved. 0.1.0-alpha.3 installedhostCeilingonCapabilityAuthorizerand consulted it once, on the decision a grant had already produced: a policy refusal had spent amaxUsesgrant by the time it was refused, and a second grant that policy would have allowed was never tried. It is now consulted per matching grant and before consumption. A refused call does not spend the grant, a refusal ends that grant's candidacy rather than the decision, and when nothing is left the reason follows a fixed precedence: the fail-closed delegation denials, thenhost_policy_denied, thengrant_exhausted, thenno_matching_grant. Discovery consults the same port, so a catalogue is never offered on authority invocation would refuse.narrowgains a fourth argument,policy, the value the turn'sPolicySourceloaded (under Added); a ceiling written against alpha.3 still compiles and runs, since the parameter is trailing and admitsundefined. A throw fromnarrowis reported to the newCapabilityAuthorizerOptions.onProviderErroraskind: "policy"— the same shapeSharedOSKernelOptions.onProviderErrortakes, declared on the authorizer because that is where the ceiling is installed; pass one function to both. A malformed return — anasync narrow, or a branch that falls off the end, both yield something whoseallowedisundefined— fails closed ashost_policy_unavailablerather than being read as a denial, and a refusal whosereasonCodeis anything buthost_policy_deniedhas it replaced, so a ceiling cannot re-emit the misattribution the separate code ends.Audit record: the
hostCeiling: truekey alpha.3 wrote onauthorization.checkedwhen a ceiling was installed is gone from that event. Everyauthority.resolvedevent now carrieshostCeiling: "installed" | "absent"andhostPolicy: "loaded" | "unavailable" | "absent", whereabsentmeans no source is installed. A host comparing either event'smetadatawithtoEqualsees the change. The manifest gains ahost-policy-deniedrow, passing in all six columns: a grant covers the frozen path, so the refusal that would otherwise readno_matching_grantreadshost_policy_denied, and the same ceiling withholds every mutation tool from discovery, so the row also asserts that what the ceiling refuses at invocation is absent from the catalogue. Both boundaries appear in one cell,envelopeandkernel.
Changed — behaviour
-
authorization.checkednow carries the decision's own metadata. Until now the event'smetadataheld only the two keys the kernel states,consumedandfailClosed, and alpha.3'shostCeilingflag, which moves toauthority.resolved(under Changed — breaking). It now also carries whatever the decision carried: aHostCeiling's own keys, and — new to audit, though it has existed on the decision all along — thedelegationdetail (codeandgrantId) behind adelegation_chain_invalidordelegation_chain_unverifieddenial. Hosts persist audit events under closed schemas of their own, so this is a change to record. The kernel's two keys are stripped from the decision's copy rather than overwritten, so no port can set them. -
GrantSourcereturns the grants the actor holds and applies no policy. The host guide previously instructed the opposite — apply the ceiling in the source, by not returning the grant it forbids. That is the one refusal path that misreports itself: the kernel recordsno_matching_grantwhile the grant sits in the store. Policy moves to theHostCeilingabove. Nothing in the code enforces this, and nothing can — SharedOS never sees what a source withheld — so it is a contract, and thehostCeilingflag on every authority load is the most a reader gets: it says whether a policy port exists, not whether the source stopped filtering.The trade is worth stating:
AuthoritySnapshot.hashnow identifies authority held, not authority usable. A snapshot may list grants a ceiling will refuse, so an auditor reading one alone overstates what a turn could do and has to read the decisions as well. -
The conformance judge is at version 4: a row graded on how the turn ended is no longer failed when the delegate never asked for that ending. For an
escalateterminal the ending is elected by the delegate, and a live column is a real model that may answer the prompt and stop; version 3 reported that choice as a SharedOS failure, and asymmetrically -- a column that issued nothing at all was alreadynot exercisedon the control guard, so the more cooperative column graded worse for the same behaviour. The judge now reads the ask from the record:sharedos.escalateamong the operations (a delegate that did not recognise the name and forwarded the call) or the newescalation.askedruntime event (one that did). Neither trace gradesnot exercisedand says so in the cell; either trace with an unmet ending is stillfail. Only theescalateterminal is treated this way. No scripted cell moves, and the case-set and world-set hashes are unchanged; artifacts graded under version 3 and version 4 are not cell-comparable on the escalation row. -
The ask is announced before the turn ends on it. Every path that honours
sharedos.escalateends the turn without forwarding the call, so a working ask left no operation in the record -- and neither would one the envelope then failed to honour. The standard loop, the MCP latch and the conformance adversary now emitescalation.askedthroughRuntimeHost.emitthe moment the affordance is recognised, carrying the tool name and reason; it lands in the record as aruntime.eventwhatever the turn then does. Announcing is for the record only: a host that will not take the event does not change what the delegate decided. It is the delegate's own claim and can only make a row grade harder, never credit a pass. Distinct from the kernel'sescalation.requestedaudit event, which records an escalation the envelope honoured. Additive; a host readingruntime.eventsees one more type.
Added
-
The native harness's translation cost is measured, and its layer can be read on its own.
pnpm benchnow prices the model driver's parse-and-translate per call beside the four vendor adapters', through the driver's own functions rather than a copy:decodeChatCompletion,encodeModelMessage,readModelToolCallandmodelToolResultMessageare exported from@aicoo/sharedos-adapters, extracted unchanged fromModelClientandModelDriver, andToolNameCodecaccepts any{ name }list.docs/conformance/systems-cost.mdgains amodel.chat-completionsrow, 123 µs per call on the reference machine, where it read not measured. Additive for a host; nothing the driver does on a turn changed. -
A turn is told where it may operate, and a remote caller can ask.
SharedOSKernel.reach(context)answers the turn's own reach: the namespace, path, actions and scope of every place some grant would authorize something at this instant, with no grant id, issuer, expiry or budget -- the derivationreadAgentCardperforms for a subject, pointed at the caller. The execution envelope reads it once per turn and hands it to the runtime asRuntimeVisibleContext.reach, narrowed byreachThroughToolsto the namespaces the turn's catalogue operates on, so a driver can tell a model where to look without the host reading raw grants to write a prompt.ReachResultmoves to@aicoo/sharedos-contractsas a schema and gainsauthority_unavailablebesideusage_store_unavailable; a reach that cannot be established is handed to the runtime asunavailablerather than as an empty list, and the turn still runs -- a call that depends on the unreadable budget fails closed on its own, as before.GET /v1/reachandSharedOSClient.reach()serve the same answer to a caller driving its own loop over the API. Host note:TurnKernelnow requiresreach; a host passingSharedOSKernelis unaffected, a narrow test double adds one member. The narrowing is keyed on the resource namespace a tool requires a capability over, not onenabledToolNamespaces, because tool namespaces and resource namespaces are different vocabularies (messagesoperates onsharedos.messaging) and the resource plane is not gated by tool namespaces; the kernel's answer and the HTTP route are therefore grant reach, unfiltered. ADR 0021 records the decisions; PRs #12 and #13 are superseded. -
A conformance row for the route lease.
route-lease-revokedsends twice on one turn's authority with the host's route lease revoked between the dispatches, so a send the kernel authorized is refused at delivery and terminates rather than delivering. See ADR 0025. -
@aicoo/sharedos-precedentdecides whether an auto-decision may stand in for a person. A new opt-in package:PrecedentKeyandprecedentKeyDigestgive the mechanism a typed key instead of a string-encoded one,PrecedentLookupkeeps the rows host-side, andadmitAutoDecisionapplies ADR 0022's R1-R4 to a proposal a host's matcher already made. It never widens: a refusal carries no width to read, R2 reusescapabilityIsWithin(now exported from@aicoo/sharedos-core), R3 takes the tightest envelope withdelegationDepth: 0, and R4 marks every decision so a matcher's whole output can be revoked at once. Nothing here is anAuthorizationDecision, andescalation.auto_decidedjoinsAuditEventType. -
One ordering for a constraint envelope.
tightestConstraintsandconstraintsAreWithinin@aicoo/sharedos-coreare the meet and the containment check overpurposes,notBefore, andexpiresAt. Delegation's attenuation and derivation checks and precedent R3 all read the same one, so the three places that each spelled it out cannot drift. One alignment falls out: a present timestamp that does not parse now violates its field on either side, where attenuation previously ignored a malformed child bound beneath an unbounded parent. -
SharedOS can describe an agent, not only address and authorize one.
SharedOSKernel.readAgentCard(context, subject, { view })serves a card made of identity and computed reach and nothing else. Reach is derived at read time, byCapabilityAuthorizer.reach, from the grants in force at that instant, and is never stored: a stored reach is the one description of authority nothing invalidates, because revocation, purpose withdrawal, expiry and a spent budget all work by not matching at the next decision. Reading a card is itself authorized oversharedos/["directory", <subject>]/read(agentCardCapability,directoryCapability); without that gate the directory answers "does this agent exist", and through reach "what resources exist and where", in one call rather than one refusal at a time. Nothing is consumed, and the subject's grants are not loaded until a reader has been authorized to ask about that subject. A card is a view rather than a record:identityandnamespacesare narrower resources beneath the subject's own path, so a less-authorized reader is served a narrower card and is told which views it may still ask for. The card carries no grant id, issuer, expiry or budget, and no display name, avatar or skill -- a host composes those around it. Reach is grant reach: the host ceiling is not consulted, so a card can name a path product policy refuses, and the refusal names the ceiling. A bounded grant whose usage store is missing or throws refuses the cardusage_store_unavailablerather than narrowing it (CapabilityAuthorizer.reachanswers aReachResult, a contract type in@aicoo/sharedos-contracts); a spent budget still omits the grant. Host note: aGrantSourceis now called with a context whose actor is not the caller, so one that reads an ambient session user instead ofcontext.actoranswers with the wrong principal's grants; SharedOS refuses such a card asgrant_scope_mismatchrather than serving it, but a source that filters by session and returns nothing understates silently. See ADR 0021. -
Every refusal reaches audit, and the record names the boundary that made it. The execution envelope made no audit call of its own: a tool name the turn's catalogue never offered, a spent step or tool-call budget, a context mismatch, and how a turn ended existed only in
ExecutionResult.events— a required field hosts pay for on the wire whose every consumer in this repository is the conformance package. A host with an audit sink could not see the clearest attempted violation the system produces (ADR 0023).AuditEventTypegains one value,turn.ended: one event per turn, at the terminal, carrying the outcome and reason. Not one per transition — that would triple the audit volume of every successful turn to say nothing more, and aturn.deniedwould double-count against theauthorization.checkedadmission already produced. A cancelled turn isfailedwith reasonturn_cancelled;AuditOutcomeis unchanged.SharedOSKernelgainsrecordTurnEndandrecordRefusedCall, and the envelope calls them through the same optionalTurnKernelmembersrecordEscalationalready uses, so hosts wire nothing new — a secondAuditSinkoption would be one a host can forget to pass twice, and the failure mode of forgetting is a turn that enforces correctly and records nothing.Two
metadatakeys carry the rest.sourceiskernelorenvelopeon every operation and terminal event; it is required by the change rather than incidental, because the rule "it is in audit, therefore the kernel refused it" held only while the envelope recorded nothing.causedisambiguatestool_unavailable—not_registered,namespace_disabled, the discovery decision's own code, ornot_offeredfrom the envelope — whilereasonstays the code the caller was given, so one refusal keeps one name.errors.mdhas promised that disambiguation all along and delivered it for one of the three situations; it now holds for all of them, including a policy refusal, which could otherwise only ever appear on a decision event.tool.catalog.listedgainswithheldCount, withfailClosed: truewhen what it withheld, it withheld by an outage. Discovery refusals had no code on either side before — the caller is not told and the record said nothing either. The listing's other new keys are under Changed — breaking.Kept out deliberately: the MCP transport's
unauthorizedrefusal, which happens before anAccessContextexists and would need a fabricated principal to record; the thrown error behind any refusal, which stays on the diagnostic hooks; payloads, unchanged; and the parser detail behindinvalid_tool_arguments, which quotes the value that failed. -
The ceiling's policy can be loaded per turn, beside the grant set.
SharedOSKernelOptions.policySourceinstalls aPolicySource, one asynchronousload(context, signal)the kernel calls once per turn, in flight beside the grant load, and holds on the turn's authority lease asResolvedAuthority.hostPolicy. It resolves to aLoadedPolicy,{ policy, version }.HostCeiling.narrowgains a fourth argument,policy, which is thepolicythat source loaded — exactly as loaded, not cloned or validated, because SharedOS does not know its shape and reads nothing from it — and isundefinedwhen no source is installed, so a ceiling that closes over its own state is unchanged.HostCeiling<Policy>andPolicySource<Policy>take the type as a parameter for the host's own documentation; the pairing is not checked. This is the second port ADR 0020 defined, and the reason the synchronous signature can serve a policy that lives in a database: it is read once at the turn boundary, the way authority is, and never on the authorization path.versionis the one thing about a policy SharedOS reads: the source's own name for what it loaded — a revision, an etag, the hash of the table it read — recorded ashostPolicyVersionon everytool.catalog.listedevent in the turn, besideauthorityHash. An opaque value has no canonical form to hash, and the record needs to pin a catalogue to the policy state it was decided against, so the source says.It fails closed the way the grant source does. A throw, or a result that is not a
LoadedPolicy, is reported once toSharedOSKernelOptions.onProviderErroraskind: "policy"and the turn's policy is heldunavailablefor its whole length: every decision the ceiling would have been consulted on is refusedhost_policy_unavailablewithoutnarrowbeing called, on both paths and before any bounded use is consumed. A kernel with no ceiling ignores it. A cancelled load re-throws the abort.Host notes. Nothing changes for a host that installs no source. A
HostCeilingwritten before this release still compiles and still runs: the new parameter is trailing and admitsundefined. Everyauthority.resolvedevent gainshostPolicy: "loaded" | "unavailable" | "absent"besidehostCeiling, whereabsentmeans no source is installed; a host comparing that event'smetadatawithtoEqualsees the new key. ThePolicySourcerow leavesdocs/open-items.md. -
SharedOSKernelOptions.onProviderError, so a contained throw is diagnosable. A provider, tool handler, transport, or router that throws is answered with a fixed reason code --tool_execution_failed,resource_execution_failed,message_delivery_failed, and the four other codes the seven contained call sites return -- and the error itself was discarded, so nothing said which provider broke or where. It now reaches this optional hook, whole and unwrapped, with aProviderErrorContextnaming thekindof port (tool,tool_catalog,resource,message), thereasonCodereturned in its place, the trace and namespace, and whichever ofoperationId,tool,resource, andactionthat path has. One hook rather than one per port:kindis what a host branches on to route them differently, and a port added later is covered by the hook every host already installed.reasonCodeis the same code audit recorded and the same one the agent was told, so a log line joins to both.Nothing reaches the wire: every message and audit event is unchanged, and a kernel with no hook installed takes the same decisions. The hook is observational -- one that throws is ignored -- and synchronous, unlike
onAuditError, which is awaited because it fires after the side effect; this one fires mid-flight, where awaiting a host's logger would put its latency on every failed call. A cancelled operation is not reported.The one behavioural change: a
ContextToolProviderwhoselistToolsthrows is still wrapped into one catalogue-failure sentence, but the provider's error is now that wrapper'scauserather than being destroyed. That is visible without the hook -- a logger or reporting SDK that walkscausewill print the provider's message where it previously printed nothing -- so it is a change to what a host may see, not a no-op.Four symbols join the public surface of
@aicoo/sharedos-core, and through it@aicoo/sharedos:ProviderErrorContext,ProviderErrorKind,ProviderErrorReporter, andreportContainedError. The last is the swallow guard itself, exported so@aicoo/sharedos-runtimeand any host offering a hook of the same shape share one implementation of the promise rather than each making their own.Not covered, and stated so it is not mistaken for done: the four authority ports still discard theirs, and they are not equally bad.
GrantSource,GrantUsageStore, andDelegationChainResolverfail closed under their ownfailClosedreason codes, so the failure is classified even though the cause is gone.CapabilityGrantVerifieris the one to watch: a throw fromverifyis treated asfalse, so the grant becomes invisible and the denial readsno_matching_grant-- indistinguishable from an actor who was never granted the capability, and not markedfailClosed. -
onTurnError, onSharedOSExecutorOptionsandStandardRuntimeOptions, so a contained throw is diagnosable. Both layers catch one and end the turn on a terminal code -- the envelope'sruntime_failed, the standard loop'sdriver_failed-- and both discarded the error, leaving an operator a code and no stack. It is now handed to this optional hook, whole and unwrapped, alongside the turn'sexecutionIdandtraceId;TurnExecutorforwards it to both, so one sink covers both. Nothing about the wire changes: eachProtocolError.messageis the same fixed string, no event carries the throw, and a turn with no hook installed behaves exactly as before. The hook is observational -- one that throws is ignored -- and a cancelled turn does not reach it. Note thatruntime_failedis also what a throw fromopenTurnAuthority,admitTurn, orlistToolsends a turn as, so read the stack rather than the code to tell a plugin's failure from a host port's. -
A conformance row for a runtime plugin that throws out of its turn. The envelope contains the throw rather than letting it reach the host: the turn ends
failedwithruntime_failed, theturn.failedevent names the envelope as what ended it, and the record still carries the call the turn made before it. The receipts survive only because the adversary emits them as they happen -- a crash carries no terminal metadata to return them on -- and the row pins that too. Only a plugin that owns its outcome can throw on purpose, so the row runs on the adversary column and every driven, MCP, and model column declares itnot applicable. The case-set and world-set hashes move. -
A
reporesource namespace, besidefiles.createRepoToolsandregisterStandardOsTools(kernel, { files, repo })publishrepo.status,repo.diff,repo.log,repo.stage, andrepo.commitover a host-owned Git provider. Afilesgrant over a working tree grants nothing underrepoand the reverse, so committing is authority a host issues rather than a consequence of file-write authority. See ADR 0024.
0.1.0-alpha.3
Changed — breaking
-
The conformance manifest's reference column is renamed.
EMBEDDED_COLUMNis nowADVERSARY_COLUMN, with idadversary-embeddedand labelAdversaryin place ofsharedos-embedded/Standard. The column is unchanged — the scriptedHostileRuntimein the seat, owning its outcome — and the rename says what it is: the reference adversary, not the harness SharedOS ships.Standardnow names that harness (below). Code importing the old constant changes the name; a reader ofkernel-conformance.json, or of a live artifact, matches the column on its new id. -
InMemoryGrantChainResolverandUnavailableGrantChainResolverin@aicoo/sharedos-testkitare renamedInMemoryDelegationChainResolverandUnavailableDelegationChainResolver, after the port they implement. The port was renamed fromGrantChainResolvertoDelegationChainResolverin this release and the fixtures kept the old name. Rename the import; nothing else changes. -
Messages now have one policy-bound reason:
purpose. The redundantMessageEnvelope.intentfield is removed, and strict parsing rejects legacy envelopes that still carry it. Outbound sends execute as their sender; an inbound turn executes as its recipient and resolves that recipient's execution, file, and tool authority independently. See ADR 0015. -
grantsis removed fromAccessContext. Authority now enters SharedOS through one required port,GrantSource, whichSharedOSKernelcalls once per turn. A context names who is asking, on whose authority, and for what; it carries no authority of its own, so nothing a caller assembles can become one. Resolved authority is aResolvedAuthoritywrapper that is deliberately not assignable toAccessContext, so grants cannot reach a provider, tool handler, transport, or runtime by accident. A source that throws, returns unparseable material, or answers outside the context's scope denies withauthority_unavailablebefore anything else runs. -
A derived grant names one parent, not a chain.
CapabilityGrant.delegation({ parentGrantId, depth, chain }) is replaced by a single optionalparentGrantId, and ancestors are re-resolved from the issuing store at every decision through aDelegationChainResolver. An embedded chain is provenance the presenter controls; re-resolution is what makes revocation mean something.CapabilityAuthorizer({ chainResolver })is now{ delegationResolver }, andGrantChainResolveris nowDelegationChainResolverwithresolvein place ofget. -
Delegation reason codes are renamed, from
delegation_chain_unavailable/delegation_chain_brokentodelegation_chain_unverified/delegation_chain_invalid. The distinction is now load-bearing: unverified means SharedOS could not establish the chain, and is grouped withauthority_unavailableandusage_store_unavailableinINFRASTRUCTURE_DENIAL_REASONS, whose audit records carryfailClosed: true. Exclude them before computing any denial rate. -
A bounded (
maxUses) parent is refused at both boundaries, asbounded_parent_not_delegable. Usage counters are per grant, so comparing a child's ceiling against its parent's reads as attenuation without bounding total consumption.deriveGrantalready refused; the chain check now agrees. -
tool_not_availableis gone. The execution envelope and the kernel emit one code for one refusal,tool_unavailable; which boundary refused isOperationRecord.source. An owner-crossing tool requirement is nowdeniedwithinvalid_requestrather thanfailedwithinvalid_tool_requirement. -
ExecutionResultgainsescalated, a third terminal state carrying anEscalationand noerror. Code that switched onsucceeded/failed/denied/cancelledand reached for.errorno longer compiles. -
deriveGrantdrops two refusal reasons that could never fire (namespace_mismatch,issuer_is_not_the_holder) and adds three that can:issued_before_parent,id_collides_with_parent, and — on the issuing side only — a refusal to pin an owner onto an unowned parent capability. -
Repository tooling, not a published contract.
pnpm conformance:liveis nowpnpm conformance:native, overscripts/native-conformance.mjs, writingnative-conformance.jsonand readingSHAREDOS_NATIVE_CONFIG. "Live" named when a run happened rather than what it measured, and both live scripts drive a real model over a real wire; what separates this one is that each vendor CLI runs on its own stdio protocol, natively, wheremcp-conformance.mjsreaches the same binaries through MCP. The column idscodex-liveandmodel-livedeliberately do not move: they are keys in artifacts already on disk.The script also takes
--configand--harness, as the MCP one already did. DeclaringcredentialVariablesmakes the pinned key required, so a harness that cannot reach it reports unavailable instead of authenticating somewhere else -- on the operator's own subscription, say, producing a column that cannot be published beside the others.
Changed — behaviour
-
The conformance judge is at version 3: a failed turn that the envelope ended names
envelopeas its enforcement point, read from theturn.failedevent's newsource, where version 2 named a boundary for denied turns only. Statuses are graded as before; a manifest or artifact produced under version 2 differs from one under version 3 in that field alone. -
turn.failedcarriessourcebesidecode:envelopewhen the envelope refused the runtime's outcome or the runtime threw,runtimewhen the envelope relayed a failure the runtime reported as its own. Additive; the event's shape is otherwise unchanged. -
A failure an adapter ends its turn with —
harness_*,model_*— now carriesretryable: falseon the driver path as it already did on the MCP path; the field was simply absent before. Nothing an adapter fails on is retryable: asking the harness or the model again asks the same thing. -
codexMcpConfignow emitsdefault_tools_approval_mode = "approve"besiderequired = true, the setting the conformance launch has always passed as an override. Codex's defaultautomode asks a human before any tool that is not read-only; a run with no human then refuses every write inside Codex with the kernel never consulted. The approval is scoped to the SharedOS server, and what secures a call is the kernel re-authorizing it. A host that wants Codex's own prompt as well can override the key. -
The four MCP harness specs derive their server name from
SHAREDOS_MCP_SERVER_NAMEand, for Claude Code's--allowedToolsand Codex's-coverrides, from the connection'sname, instead of the literalsharedos; a spec given anotherserverNameis now launched under it. -
A grant that expires while a turn is running is now refused inside that turn, at the next decision, rather than at the next turn. Revocation, purpose withdrawal,
issuedAt, andnotBeforeare unchanged: they are still decided at the instant the turn's authority was resolved, and are still observed by the next turn. The rule separating them is directional — the operation's clock may only take authority away, never hand any back — so a turn still carries the grant set it was admitted with and can never gain more while it runs. An ancestor follows the same split. See ADR 0016.Nothing about a turn's authority load changes: no store is re-read,
cost.authorityLoadsstays at 1 per turn, and a decision an expiry refused names the same authority snapshot hash as the decision before it. Hosts issuing short-lived grants should expect them to stop working part-way through a long turn, which is what they asked for; hosts relying on a turn outliving its grants' validity windows must widen those windows.CapabilityAuthorizer.authorizeandcanDiscovertake the operation instant as a new optionalnow, andvalidateDelegationChaintakes the admission instant as a new optionaladmittedAt. Both default to the previous behaviour, so a host calling either directly is unaffected until it opts in.
Added
-
The host ceiling is a port the kernel calls, not a convention hosts apply upstream.
CapabilityAuthorizer({ hostCeiling })installs aHostCeiling, one synchronousnarrow(decision, request, context)consulted only after a grant has matched, on an already-alloweddecision, on both theauthorizeand thecanDiscoverpath -- so a tool a ceiling refuses at invocation is also absent from the catalogue. Step 10 of the permission model's authorization algorithm is now true of the code. It cannot widen anything: a denial is never shown to it, its allow arm is pinned to the decision it was handed, and an allow that does not carry the samematchedGrantIdis a malfunction that fails closed. Its refusal ishost_policy_denied, a policy denial in its own bucket -- not infrastructure, and not merged withno_matching_grant, which says no such authority exists. A throw ishost_policy_unavailable, which joinsINFRASTRUCTURE_DENIAL_REASONS. The synchronous signature is the enforcement of "deterministic and cheap": a synchronous return cannot await a network or model call. It is optional, and a kernel constructed without one behaves exactly as it did, down to the audit record; when one is installed the kernel records that, so a deployment that denies everything through policy is legible rather than reading as one where nobody was granted anything. ADR 0020'sPolicySourceis not implemented and carries a row indocs/open-items.md. Hosts applying a ceiling today keep the same logic and move the call site; withholding a grant instead still producesno_matching_grantand still misattributes the refusal. -
A denial for want of a grant names the authority that would have satisfied it.
AuthorizationDecisiongains an optionalrequiredCapability, aCapabilityRequestpopulated only onno_matching_grant, where the authorizer already holds every field: the resource and action the caller named, and the owner, namespace, purpose and instant from its own access context. It is a description and not an offer -- it grants nothing, no port accepts one back as authority,allowedstaysfalse, and a host that ignores it behaves exactly as before. It is never built from anything a provider knows, so the same description is produced for a path that is absent and for one the actor cannot reach; it is not an existence oracle. The other denials deliberately carry none. See ADR 0019. -
An escalation may carry the capability it is asking for.
Escalationgains an optionalrequestandSharedOSKernel.recordEscalationaccepts one throughEscalationOptions.request, typically therequiredCapabilitya denial just described; theescalation.requestedaudit event records it, so a reviewer receives a capability instead of a sentence to reconstruct one from.id,namespaceId,requester,owner, andrequestedAtare minted from the trusted context and overwrite whatever the caller supplied -- a request the caller authored would be a caller-chosen correlation for a decision the kernel made -- andidis derived from those fields rather than generated, so one ask describes itself the same way twice. Nothing else changes: there is no third decision value, no consent port, no queue, and no resumption;Escalation.statusis still alwayspendingand nothing inside SharedOS advances it.CapabilityRequeststops being a type with no port and leavesdocs/open-items.md. -
The native harness has a committed conformance column.
Standard(MODEL_SCRIPTED_COLUMN, idmodel-scripted) isModelRuntime—StandardRuntimewith the model driver in the seat and the permission-filtered catalogue rendered into the model's tool-call shape — with a transcript where the provider would be.movesToModelTranscriptwrites each declared attempt as a model reply in the wire alphabet a provider accepts, and the driver's real codec, argument parsing, escalation recognition, and step accounting read it back; what is left out is the model. It is graded undermodelLimits, as the live model column is, so the shipped loop carries a driver's limits in a committed cell rather than standing in for the kernel it runs on: the inspection row and the ungranted-escalation row readnot applicable, and the step-ceiling rowpass (driver). The manifest goes from five columns to six; every hash is unchanged, because neither the case set nor the world set moved. -
TranscriptModelClientin@aicoo/sharedos-adapters: aModelClientthat replays a suppliedModelTranscriptthrough the realModelDriver, the counterpart ofTranscriptTransportfor a vendor harness. A spent transcript fails the turnmodel_call_failedrather than completing on the recording's behalf, so a script that ends too early is a visible result and not a model choosing to stop. -
A conformance row for an escalation the turn was not granted. A runtime that ends its turn with
escalatewhile the catalogue does not offer the affordance is refused by the envelope: the turn failstool_unavailable, and nothing is recorded or audited. Only a plugin that owns its outcome can make the attempt, so the row runs on the adversary column and every driven, MCP, and model column declares itnot applicable-- the first use ofColumnLimits.unsupported. The case-set and world-set hashes move. -
DriverRuntimein@aicoo/sharedos-adapters: the one implementation behindHarnessRuntimeandModelRuntime, which are now its two named forms. A host installing another driver under its own identity uses it directly. -
escalationOffered(tools)in@aicoo/sharedos-runtime: the catalogue gate on honouringsharedos.escalate, shared by the three adapters and the executor instead of each spelling the check. -
canonicalActorin@aicoo/sharedos-mcp: the one string form of anAddressan execution token carries asactor,<kind>:<id>, from the same pairaddressPathderives for a recipient-scoped grant. The form was described in a docblock example and defined nowhere. -
MCP_HARNESS_IDSin@aicoo/sharedos-mcp, the one listMcpHarnessIdis derived from, andCODEX_HARNESS_ID,CLAUDE_CODE_HARNESS_ID,DEEPSEEK_HARNESS_ID,PI_HARNESS_IDin@aicoo/sharedos-adapters: each adapter's manifest, requirements, and MCP spec now name the harness from one constant, checked against that list. -
codexMcpServerSettingsin@aicoo/sharedos-mcp: the settings a Codex MCP server entry carries, as key and TOML value.codexMcpConfigrenders it as a table and the Codex conformance launch passes it as-coverrides, so the two cannot disagree. -
A denial now explains itself to the host. The reason codes still collapse for the caller —
no_matching_grantcovers nine causes andauthority_unavailablecovers four, so that no caller can map the permission topology by reading refusals — but the operator who wired the store and issued the grant is not the caller.authorization.checkednow carriesgrantsResolvedand arejectedGrantsarray naming every resolved grant and the first condition it failed (issuer,subject,namespace,window,purpose,verifier,capability,delegation,exhausted), andauthority.resolvedcarries the same key for the three conditions checked before the grant loop runs.usage_store_unavailableanddelegation_chain_unverifiedadditionally carrymissingDependency: "usageStore" | "delegationResolver", which distinguishes a permission problem from a wiring one: amaxUsesgrant with no usage store, or a derived grant with no delegation resolver, denies every call it should have allowed and is otherwise indistinguishable from an absent grant.Host notes. Nothing is added to
AuthorizationDecision, so no response body changes.CapabilityAuthorizergains an optionalAuthorizeOptions.onExplaincallback for hosts that call it directly;SharedOSKernelsupplies its own and needs no change. Theunavailablevariant ofAuthorityResolutiongains an optionaldetail, so a host that compares a resolution withtoEqualrather thantoMatchObjectwill see the new field. Discovery checks do not explain: catalog filtering denies on nearly every tool by design, and routing that through the record would bury the denials somebody was looking for. -
messages.request, a canonical recipient-scoped request/reply tool. The model supplies only a recipient and JSON-safe payload; trusted context supplies the sender, purpose, trace, timestamp, and message id.MessageTransportandMessageRequestRouterremain host ports for durable delivery and reply lookup—SharedOS does not own a queue, receiver wake-up, or scheduler. Direct send and the request tool share one post-authorization delivery path, so a bounded send grant is consumed exactly once. -
A quickstart with two working programs, an HTTP API reference covering every route and status code, a tool catalog covering the twelve
filestools and how to register your own, and a reason/error code reference. None of this was documented outside the source before. -
A conformance suite: every kernel guarantee is an attempted violation run by a scripted adversary, graded per runtime, with the case set and the world it runs against hashed separately.
pnpm conformanceregenerates it. -
A conformance row for a validity window closing mid-turn, and the clock it needs. Nothing can expire during a turn when time does not move, so a condition may now arm
expiresAfterOperations, which advances the world's clock one step per mediated operation — indexed on the operations the kernel recorded rather than on wall time, so repeats stay byte-identical. It is opt-in per condition and every other condition still runs on the frozen instant. The row is separate fromrevoked-mid-turnrather than a second condition on it, because the two make the identical call at the identical position and require opposite answers. -
Escalation as a recorded outcome, per-turn authority resolution, and one refusal vocabulary across both enforcement boundaries. See ADRs 0008–0013.
-
@aicoo/sharedos-mcp: the permission-filtered catalogue served as an MCP server, which is the boundary a vendor harness actually connects to, plus Codex, Claude Code, DeepSeek Harness, and Pi adapters in@aicoo/sharedos-adapters. See ADR 0014. -
ModelDriverin@aicoo/sharedos-adapters: a model API in the delegate seat, with no vendor CLI between it and the envelope.StandardRuntimestill owns the loop, still stops atmaxSteps, and still re-authorizes every call; what changes is only who occupies the seat. Dotted SharedOS names are mapped per turn, from the catalogue rather than by guess, onto the^[a-zA-Z0-9_-]+$a chat-completions provider constrains function names to — and a name the map does not hold is passed through anyway, so a model that invents a tool outside its catalogue reaches the envelope to be refused instead of being filtered out where nothing records it. The provider'sfinish_reasonandusageare read off every completion: a reply cut off at the output-token ceiling fails the turn asmodel_output_truncatedrather than being graded as a decision the model finished making, and the turn's token spend reaches the execution record ascost.inputTokens/cost.outputTokens. Afaildecision now carriesmetadataascompletedoes, so a failed turn keeps the model that answered and what it cost. A call whose arguments do not parse is refused by the driver asinvalid_tool_argumentsand answered back to the model, never sent as{}-- an empty object is a call the model did not make, and a tool with every parameter optional would have run it. The turn's metadata counts them asmalformedToolCalls; pastmaxMalformedCalls(default 8) the turn fails asmodel_malformed_call_limit_exceeded. -
A
model-liveconformance column built on that driver, which separates what a model does from what a vendor's scaffolding makes it do — the axis every other live column confounds. It is an addition to the scripted column and never a replacement: a model chooses, so an attempt it declines leaves no operation and the cell reports not exercised rather than pass. -
Escalation as something the occupant of the delegate seat can ask for, rather than an outcome only a host-written runtime could reach.
AgentTurnDecisiongains an escalate variant, and the ask itself is published assharedos.escalate: catalogued and permission-filtered like any other tool, invisible to an agent holding no grant over it, and never invoked -- a driver whose turn's catalogue offers it recognises the name and ends the turn on it, and a call naming it on a turn that was never granted it is passed through to be refusedtool_unavailable. The envelope holds the same gate from outside:SharedOSExecutorfails a turntool_unavailablewhen any runtime plugin returnsescalateon a turn whose catalogue does not offer the affordance, recording nothing, since nothing reached the kernel. The reason is recorded as given, cut to the outcome's 512-character bound rather than replaced when it runs past it. Asking for a human is an affordance a host grants, so a host that publishes no escalation grant has agents that cannot ask. See ADR 0017. -
An optional
steponAgentTurnDecision.tool_call. A driver that says nothing is bounded exactly as before; one that names a step is refused for it if the envelope disagrees, because declaring a step is a claim and not a permission. The claim reaches forward only:StandardRuntimerefuses a step behind its own position asinvalid_driver_decision. Below the ceiling the record carries the step as declared; who declared it is the conformance report's to say. See ADR 0017. -
The same ending over MCP, where the harness rather than SharedOS owns the loop. A
tools/callfor the escalation affordance is recognised before it becomes an operation:EscalationLatchwraps the one invoker every MCP call already passes through, answers the ask, and the turn settles asescalateonce the harness has wound down. Calls made after the ask are refused in band asdeniedwithescalation_pending, rather than by closing the bridge -- closing it surfaces as a JSON-RPC internal error, which carries nothing about authority and which harnesses retry into a frame limit. The grant is checked first, so an agent without it getstool_unavailable. See ADR 0018. -
createEscalationTool()in@aicoo/sharedos-runtime: the handler a host registers sosharedos.escalateis catalogued. The definition was exported and every host wrote the same failing handler by hand; the conformance world and the adapter tests now register this one. It is never meant to run -- a driver ends the turn on the name -- and fails withescalation_not_terminatedif a driver forwards the call anyway. -
What enforcement costs, measured on both paths and reported in
docs/conformance/systems-cost.md.pnpm benchregenerates it, against a monotonic clock added for the purpose — wall time is not a duration. -
pnpm release:promote-latest <version>moves thelatestdist-tag across the whole package set in one command, refusing to act unless every package has published that version.
Removed
release:check:private, and the--allow-privateflag onscripts/release.mjsbehind it. The flag required every package to beprivate: trueunder anUNLICENSEDlicense, the preparation state the packages left before0.1.0-alpha.0was published; with all eleven public and Apache-2.0, the command could only throw.release:checkis the one release check.MCP_HARNESSESfrom@aicoo/sharedos-adapters/node. Nothing read it: the conformance script names the four specs it runs, and a host picks one.strictToolPolicyandListToolsParamsSchemafrom@aicoo/sharedos-mcp. Neither had a caller;declareToolPolicy({ mode: "strict" })is what the helper built, and thetools/listhandler takes no parameters.- The
config.tomlthe Codex conformance spec wrote into its temporary workspace. Codex reads$CODEX_HOME/config.toml, never the working directory, and the launch has always passed the connection as-coverrides, so the file was written and never read.codexMcpConfigstays, for a host that configures a persistent Codex. - The MCP harness runtime's own
turn_cancelledoutcome. UnderSharedOSExecutorit was unreachable -- the executor races the plugin against the signal, and its owncancelledresult wins -- and it gave the code a second shape,retryable: falseagainst the executor'strue. A run whose signal is aborted now rejects with the signal's reason, as any aborted operation does;docs/errors.mdno longer lists the code under Adapters.
Fixed
-
Catalogue comparability is compared per case and per column instead of pooled across a whole run. The check warned whenever a run had served more than one catalogue, which was right while a run served exactly one and wrong as soon as the tool set became permission-filtered per case. Two columns are comparable when each saw the same catalogue for the same case, so that is what is now checked, and the warning names the first case where two columns actually diverged.
catalogHashByCasegoes into the artifact beside the pooled list, because a pooled list cannot show a reader that two columns saw the same tools for the same row. -
Five embedded build constants — the DeepSeek, Pi, and MCP adapter versions among them — were left at
0.1.0-alpha.0while their packages moved. They name the build that produced an execution record, so a stale one misattributes evidence.release:checkguarded two of the seven and now guards all of them. An eighth, the version the MCP server reports ininitialize.serverInfowhen built withoutserverInfo, was still0.1.0-alpha.0; it is nowMCP_SERVER_VERSION, exported from@aicoo/sharedos-mcpand guarded too. -
The publish order listed
@aicoo/sharedos-conformanceahead of@aicoo/sharedos-mcpand@aicoo/sharedos-adapters, both of which it depends on, so a run that stopped part-way could leave it on the registry with unpublished dependencies.scripts/package-set.mjsis now in dependency order,release:checkrefuses an order that is not, andtest:releasechecks the order against the manifests. -
SharedOSClientOptions.tokenandSharedOSCallOptions.purposewere undocumented: the HTTP reference listed onlyheaders, and an earlier note here saidtokenhad never existed. Both have been there since the client was written.tokenis a value or an async function sent as a Bearerauthorizationheader,headerscarries anything else, and per-callpurposesetsx-sharedos-purpose, the header the quickstart'sresolveContextreads. All three are now in the HTTP reference and the client README. -
Publish verification retried the same registry URL with default caching, so a CDN edge holding a pre-publish
404made the whole window unwinnable and a successful release reported failure. It now backs off up to five minutes and requests an uncached response each time.
0.1.0-alpha.2
Added
deriveGrantandGrantChainResolverin@aicoo/sharedos-core: a grant holder can pass on a strictly narrower slice of what it holds without the resource owner writing the row. Every axis — path, actions, scope, purpose, time window, and chain length — is checked separately, and a request that is not within the parent is refused rather than clamped. The chain is bound at use time, so a later revocation or expiry upstream still invalidates everything derived from it. (#8)examples/fleet-delegation, a runnable delegation walkthrough in workcell vocabulary. (#8)examples/reference-host, a working host: a filesystemfilesprovider covering all twelve actions with path-escape defences, durable SQLite stores for bounded uses, revocation, namespace settings and audit, and anAgentTurnDriverover a live model.
Changed
@aicoo/sharedos-contractsowns the context capsule that crosses an agent boundary. (#9)
Host notes
CapabilityAuthorizeracceptschainResolver. A host that issues derived grants must supply one; without it, derived grants are denied withdelegation_chain_unavailable.- A bounded (
maxUses) grant cannot be delegated. Sharing one use budget across a chain needs usage accounting that spans grants, soderiveGrantrefuses withbounded_parent_not_delegableinstead of multiplying the budget by the number of delegates.
0.1.0-alpha.1
Added
- Pluggable runtimes inside a fixed security envelope:
RuntimePlugin,RuntimeRegistry,SharedOSExecutor, andStandardRuntime(ADR 0007). - The
@aicoo/sharedos-httptransport adapter and the@aicoo/sharedos-clienttyped client over the same contracts.
0.1.0-alpha.0
First public prerelease of the eight @aicoo/sharedos-* packages under
Apache-2.0.