@sema-agent/core
Version:
Stateless, task-oriented AI agent core
99 lines (98 loc) • 7.43 kB
TypeScript
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;