@sema-agent/core
Version:
Stateless, task-oriented AI agent core
63 lines (62 loc) • 5.28 kB
TypeScript
/**
* design/80 D-1 §2 (slice 1a.2) — the engine-only **vetted canonical serializer** used to mint a stable,
* opaque {@link boundInputHashOf | boundInputHash} over a pending tool call's arguments.
*
* ## Why a bespoke serializer (and why engine-ONLY)
* The package's only runtime deps are typebox + the MCP SDK — there is no canonical/stable JSON serializer,
* and `JSON.stringify` is **not** canonical (object key order is insertion order, `-0`/`NaN`/`Infinity` are
* mishandled, and it offers no type-tagging). The review r3 ruling (design/80 §7) is the load-bearing
* constraint: **never let two runtimes each compute a canonical form and compare** — a `JSON.stringify`
* divergence (or a serializer-version drift across a worker upgrade) would produce a false mismatch and
* fail-closed a *legitimate* approval. So the boundInputHash is **server-minted opaque**: the engine computes
* it ONCE at suspend-mint with THIS serializer, persists it on the checkpoint, and a resume verifies by
* **opaque string equality** against the persisted value (it never re-serializes the resume-side args). This
* serializer therefore only needs to be *internally deterministic* (same value → same bytes within one
* runtime); it is deliberately NOT exported from the package index, so no cross-wire consumer (the SDK /
* service) can be tempted to recompute it — they treat the hash as opaque and echo it verbatim.
*
* ## Canonical form
* A total function (never throws) that emits a **type-tagged, length-prefixed** string so distinct values of
* distinct types can never collide (`number 1` ≠ `string "1"` ≠ `boolean`, and `{"a":"b"}` ≠ `{"ab":""}`):
* - `null` → `N`, `undefined` → `U` (encoded distinctly — `{a:undefined}` must not collide with `{}`)
* - boolean → `b1` / `b0`
* - number → `n:<canonical>`; finite via `String(x)` (shortest round-trippable, deterministic per value);
* `-0` normalized to `0`; `NaN`/`±Infinity` get explicit distinct tokens (never collapsed to `null`)
* - bigint → `i:<value>`
* - string → `s<byteLen>:<raw>` — **length-prefixed, NOT normalized**: equal inputs map to equal output and
* distinct inputs (e.g. a non-NFC string vs its NFC form) map to distinct output. The serializer never
* silently Unicode-normalizes (that would let two different argument payloads share one hash).
* - array → `a<len>:[<el>…]` — order preserved (semantic)
* - object → `o<count>:{<keyLen>:<key>=<val>…}` — **own enumerable keys sorted** by UTF-16 code unit, so
* `{a,b}` and `{b,a}` canonicalize identically. (Duplicate keys cannot exist on a live JS object —
* last-wins already happened at parse/construction — so they need no special handling here.)
* - function / symbol → tagged tokens (`fn` / `y:<desc>`) so the serializer stays total; these never appear
* in design/37 post-hook tool args (which are JSON values), but encoding rather than throwing keeps a
* weird arg from silently degrading a durable suspend to a non-durable fallback.
* - non-JSON OBJECT brands (Date/Map/Set/RegExp/typed arrays/boxed primitives/…) → a **type-discriminator
* token carrying their state** (see {@link encodeBranded}). Built-ins keep their state in internal slots,
* so encoding them by own enumerable keys alone folded every one of them onto the SAME empty-object form:
* `new Date(0)`, `new Date(9e12)`, a `Map`, a `Set` and `{}` all minted one hash, and an equality check
* over that hash accepted a substituted payload (false-accept — the direction a binding cannot afford,
* since a mismatch is visible and a match is not). Reachable only when a DEPLOYMENT supplies non-JSON
* values (model-produced args are JSON; a hook / policy rewrite is the way in).
*
* ## Compatibility boundary of the brand tokens
* JSON values (null/boolean/number/string/array/plain object) hash **byte-identically** to before the
* discriminator existed — the brand is read from `Object.prototype.toString`, and everything that reports
* `[object Object]` (including a class instance with no `Symbol.toStringTag`, and a cross-realm plain object
* whose `Object.prototype` differs) keeps the own-enumerable-keys encoding. Only NEWLY minted hashes over
* non-JSON values move. That is safe by construction for the persisted ones: the hash is opaque and
* server-minted, a resume compares the persisted string and never re-serializes.
*/
export declare function canonicalize(value: unknown): string;
/** What {@link canonicalize} returns when even its top level cannot be encoded — a reader that must never accept a
* constant (the checkpoint row digest) refuses on it. */
export declare const UNCANONICALIZABLE = "<uncanonicalizable>";
/**
* design/80 D-1 §2 — the server-minted **opaque** boundInputHash: a SHA-256 (hex) over {@link canonicalize}.
* Bound at suspend-mint to the pending tool call's input (`pendingAction.args`) and persisted on the
* checkpoint; a resume verifies the echoed value by string equality (never re-hashing the resume-side args).
* The hex digest is fixed-length and opaque — exactly what the operator sees / the D-G signer signs.
*/
export declare function boundInputHashOf(value: unknown): string;