agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
90 lines (89 loc) • 5.29 kB
TypeScript
/**
* artifacts/scopePath — the ONE owner of "a scope becomes path segments".
*
* Every durable adapter partitions its storage by the scope tuple, and every
* one of them takes CALLER STRINGS (`tenant`, `principal`, `conversationId`)
* and turns them into names inside a hierarchy: directories on a filesystem,
* key prefixes in an object store. That is a traversal surface, and it must
* be closed the same way in all of them — one encoding, one place, so a
* hostile tenant cannot be inert on disk and a navigation hop in a bucket.
*
* The law, unchanged from the directory adapter that first stated it
* (9.21.0), now shared rather than copied:
*
* • encode from the FIELD, never from a joined namespace — a tenant
* containing `/` must not become two segments;
* • `encodeURIComponent` leaves `.` alone, so dots are encoded too:
* a tenant of literally `'..'` would otherwise survive as a real
* parent-directory hop (`%2E%2E` is a NAME);
* • an empty or absent field collapses to `_`, mirroring
* `identityNamespace`'s stable layout, so "no tenant" is one shape and
* not two — and a field whose VALUE is `_` is escaped away from that
* marker, so absence and the tenant named `_` are not one directory.
*
* After this, `..`, `/` and `\` arrive as data and land as literals. The
* adapters assert `isArtifactRef` on the last segment anyway, because
* "cannot happen" is a claim, not a defence.
*
* The encoding is INJECTIVE, per field and therefore per scope: two different
* scopes never address one directory or one key prefix. `encodeURIComponent`
* supplies that for every character (it escapes `%`, its own introducer, so
* an already-escaped-looking value cannot land on the value that produced
* it); {@link distinctFromAbsent} supplies it for the absence marker, which
* `encodeURIComponent` passes through untouched because `_` is unreserved.
*
* ── Injective is not enough: the NAME has to be case-unambiguous too ─────────
* Two different strings are two different names only where the namer agrees.
* macOS/APFS and Windows/NTFS are **case-insensitive by default**, so a scope
* encoding that is injective as a string can still put two tenants in one
* directory — which is what this file did until 9.44.0. A tenant of `Acme` and
* a tenant of `acme` encoded to two distinct segments and landed in one folder,
* and since the ref is a content address that both scopes then resolve, the
* neighbour could read, LIST and delete. Reproduced on a stock Mac; the store
* conformance battery passed all 106 cases in the same process, because it had
* pairs for separators, absence markers and pre-escaped values, and no pair
* that differed only in case.
*
* So an uppercase ASCII letter is escaped BEFORE `encodeURIComponent`, leaving
* an output whose every letter is lowercase except the hex digits inside
* `%XX` escapes, which are always uppercase and can never be preceded by a
* lone `%` in any other way. No two distinct outputs can differ by case alone,
* so folding them is not something a filesystem is able to do.
*
* The escape is `~`, doubled when it appears in the input — the same
* escape-the-escape shape `identityNamespace` uses (9.40.0) and for the same
* reason: it makes a decoder a left inverse, which is what "injective" means
* when you have to prove it rather than assert it. `~` survives
* `encodeURIComponent` untouched because it is unreserved.
*
* **What re-keys.** Only a scope field containing an uppercase ASCII letter
* moves. Every all-lowercase field — including one carrying `/`, spaces, dots
* or non-ASCII — encodes to exactly the bytes it did before, so the ordinary
* deployment migrates nothing. A field that DID contain an uppercase letter was
* sharing a directory with its case variants on two of the three major
* platforms, which is the condition being fixed.
*/
import type { ArtifactScope } from './types.js';
/** One RAW tuple field → one inert path/key segment. */
export declare function scopeSegment(raw: string | undefined): string;
/** The three encoded segments of a scope, in the fixed tenant/principal/
* conversation order every adapter lays out. */
export declare function scopeSegments(scope: ArtifactScope): readonly [string, string, string];
/**
* The object-key prefix for one scope: `[<root>/]<tenant>/<principal>/<conversation>/`.
*
* Trailing slash included on purpose — it is what makes a prefix listing
* exact. Without it, scope `c` would also list scope `c2`'s keys, which is
* the isolation bug this whole module exists to prevent.
*/
export declare function scopeKeyPrefix(scope: ArtifactScope, root?: string): string;
/**
* Validate an operator-supplied key ROOT at construction (the prefix a bucket
* is shared under). It is not caller data — an operator writes it — so it is
* not encoded; it is CHECKED, and refused where it would be ambiguous.
*
* @param adapter the factory name, for the refusal's first word.
* @param root the raw option, or undefined.
* @returns the normalized root (no leading/trailing `/`), or undefined.
*/
export declare function normalizeKeyRoot(adapter: string, root: string | undefined): string | undefined;