UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

99 lines (98 loc) • 7.43 kB
import type { SessionTreeEntry } from "../harness/types.js"; /** * session-sync — the SHARED content digest for a session entry log. * * ## The gap this closes * `classifySyncRelationshipByIds` compares only the SET of entry ids. Entry ids are uuidv7, not content * addresses, so "same ids, different payload" is structurally ordinary — and the wire accepts whatever * entries a client sends. The measured consequences were: an `identical` 200 returned while the destination * still holds different text; a `fast_forward` classified "clean append, safe to apply" while it destroys * corrections on the destination without the 409 that promise depends on. * * ## Why RE-computing a canonical form on both sides is safe HERE (and is not, elsewhere) * There is a load-bearing ruling against exactly this pattern — design/80 r3, "绝不让两个运行时各算 * canonical-JSON" — which is why `boundInputHash` is minted once and treated as opaque by every consumer, * and why `canonicalize` is not exported from the package index. Read at the source rather than through its * paraphrase in `canonical-json.ts`, that ruling rests on TWO legs, and only one of them still stands: * * 1. **There was no vetted canonical serializer.** The verbatim reasoning is "deps 只 typebox+MCP、core 无 * canonical/stable JSON serializer、JS JSON.stringify 非规范". The remedy prescribed in the same * sentence was "ship 一个 vetted canonical serializer" — which shipped as `canonical-json.ts`. This leg * described the state of the codebase at the time, and it no longer holds. * 2. **The blast radius of a false mismatch**: "false-mismatch 会 fail-closed 拒掉合法审批". A serializer * divergence on the approval path DENIES a legitimate human decision. This leg stands, and it is the * one that decides whether the ruling transfers to a new use. * * Here the blast radius is inverted, and deliberately so: * - digests EQUAL ⇒ short-circuit as `identical`; * - digests DIFFER ⇒ **do not short-circuit** — run the full sync path. * * So a serializer divergence between two core versions costs extra work (a sync that would have short- * circuited now runs), never a wrong answer and never a refusal. That asymmetry is the whole reason this * function may be exported while `canonicalize` itself stays internal. **Consumers must preserve it**: a * digest mismatch may never be turned into an error, a 409, or a refusal to sync — only into "compare * properly instead of trusting the ids". * * ## Versioning * `test/session-log-digest.test.ts` freezes the digest of a fixture that exercises every value shape the * serializer distinguishes. If a change moves a frozen value, the documented response is to ADD a row for a * new {@link SESSION_LOG_DIGEST_SCHEME} — never to edit a frozen value in place, because peers on different * core versions compare these strings to each other and the old row records what was on the wire. * * A scheme mismatch is reported as "not comparable", which — by the rule above — degrades to the full sync * path, exactly like a value mismatch. * * ## What is hashed, and why not the engine's canonical serializer * v1 hashed `canonicalize` (the approval-binding serializer). An falsification review of the shipped v1 found * that choice unsound for a WIRE digest, on four counts that all trace to the same root — that serializer is * built for a single runtime comparing a value to ITSELF, not for two runtimes comparing what they hold: * * - It distinguishes `{a: undefined}` from `{}` by design. On a wire there is no such distinction, and core * itself produces own-keys-set-to-undefined routinely (clearing a label; a custom entry with no data). So * the SAME log digested different on an in-memory backend than after a JSON round-trip — measured end to * end — and the short-circuit this whole mechanism exists for would simply never have fired. * - Its number token has no terminator, so a numeric value can run into the following key: two genuinely * different payloads produced one canonical form (demonstrated). * - It collapses every host object to `o0:{}` (any two Dates, any two Maps) and everything past a depth * limit to one sentinel — acceptable when the alternative is refusing an approval, not acceptable when the * consequence is "these two logs are the same, skip the sync". * - Lone surrogates became U+FFFD at the UTF-8 hashing step, so a payload that survived one byte round-trip * on one peer collided with the original on the other. * * v2 hashes a **stable JSON serialization of the JSON-representable form** instead: each entry goes through * `JSON.stringify`/`parse` (so both peers digest the same thing the wire carries), then a key-sorted * serializer. JSON's own grammar is unambiguous — strings are quoted, numbers are delimited — so the * adhesion above cannot occur; `JSON.stringify` escapes lone surrogates as `\uXXXX`; depth is unlimited; and * a Date/Map has already become its wire form before hashing. It also means this module no longer shares a * serializer with the approval path at all, which removes the tension with that path's ruling at the root * rather than arguing about its blast radius. * * ## What is covered * Every field of every entry that survives to JSON, in the order given — id, parentId, type, timestamp and * the whole payload. Key ORDER within an entry is not (that is what the sort is for); entry order is (a * reordered log is a different log — one of the three measured failure shapes). */ export declare const SESSION_LOG_DIGEST_SCHEME = "sema-log-v3"; /** Digest of ONE entry (same scheme and rules as {@link sessionLogDigest}; useful for per-entry reporting). */ export declare function sessionEntryDigest(entry: SessionTreeEntry): string; /** * Digest of an ORDERED entry log. Two logs with the same digest hold the same entries, in the same order, * with the same payloads and the same parent structure — as far as JSON can express them. * * Length-prefixing each entry's serialized form keeps concatenation unambiguous (without it, two adjacent * entries could be re-partitioned into a different pair with the same bytes). The domain tag (`log` vs * `entry`) keeps the two digest families from ever being equal by accident. */ export declare function sessionLogDigest(entries: readonly SessionTreeEntry[] | null | undefined): string; /** * Are two digests comparable at all? False for a different scheme version, an absent value, or anything that * is not a well-formed digest string. * * Every rejection path returns `false` rather than throwing. That is load-bearing: this function sits on the * classification path, and the contract it serves says a digest problem must degrade to "do the full sync", * never to an error. v1 guarded only `undefined` — but JSON has no `undefined`, so "the peer sent no digest" * arrives as `null`, which threw. v1 also derived the scheme with `slice(0, indexOf(":sha256:"))`, and a * missing separator yields -1, so any string shaped like the scheme plus one character passed as a valid * digest of that scheme — two peers holding the same truncated non-digest would have compared equal. */ export declare function sessionLogDigestsComparable(a: string | null | undefined, b: string | null | undefined): boolean;