/** * h2a → sentropic session-exposure feed — DESCRIPTOR BUILDERS (P1, step 1). * * Implements Part A of the ratified contract * `docs/superpowers/specs/2026-07-24-h2a-feed-contract-for-sentropic.md` * (RATIFIED by the sentropic architect, 2026-07-24, with three binding * conditions accepted). Sentropic owns the multi-tenant gateway, the 39-auth * broker and the UI panel; this module is h2a's own surface only. * * Everything here is PURE: presence records + registrations in, opaque * descriptors out. No I/O, no network, no auth, no push, and never a clock — * `asOf` is always injected by the caller, so every derivation is * deterministic and unit-testable. * * Governing rule of the contract, enforced structurally in this module: * *if a field can't be shown safely in a browser, it doesn't belong in the * feed.* No message bodies, no negotiation content, no keys or tokens, and no * filesystem path — `workspaceLabel` reads `H2AWorkspaceRef.label` and NEVER * `.path` or `launchContext.cwd` (Part A, "Opacity boundary", non-negotiable). * * NOT wired into the hosted MCP handlers yet: that is step 5 of the P1 plan and * is gated on the architecture lane's per-principal root partition (Part C). */ import { type H2AActorRegistration, type H2ARole, type H2ASession } from "@sentropic/h2a"; /** * Per-descriptor liveness. `stale` is NOT a synonym for `idle`: see * {@link deriveLiveness} — it is a FEED-PIPELINE freshness signal, not an agent * state. */ export type H2ALivenessState = "live" | "idle" | "stale" | "closed"; /** An agent instance as it may be rendered in a browser. Opaque, non-secret. */ export interface InstanceDescriptor { /** * `H2ASession.instance` / `H2AActorRegistration.instance` — the addressable * `host:slug(label):uuid12` handle frozen at mint (DEC-114). Shown verbatim * because P1 is "read your OWN data": this is the resource id of the * principal's own agent, not a counterpart reference. */ readonly instanceId: string; readonly displayName: string; readonly host: string; /** * `H2AActorRegistration.roles[0]` **validated against `H2A_ROLES` on read**, * or the literal `'unknown'` when the instance has no registration, declares * no role, or declares a string outside the enum. * * The union is deliberate (architect ruling, 2026-07-25): a MISSING role must * never be rendered as a real `H2ARole`. Synthesizing e.g. `'AGENTS'` would be * indistinguishable from an asserted claim, so once real roles land * (PRINCIPAL/CONDUCTOR/CONTROL) an absent role would silently read as a * genuine claim of authority and no consumer could tell the two apart. * * Validation on READ is what makes the union true on the wire whoever wrote * the registry: the store re-validates nothing on read, `mirror/accept.ts` * authorizes a mirrored registration by key ownership without constraining its * content, and `mcp/handlers.ts` applies a caller-supplied registration. A * `'SUPERADMIN'` planted by any of those paths must surface as `'unknown'`, * never as an out-of-union string in a typed consumer. */ readonly role: H2ARole | "unknown"; /** Human label only. NEVER a filesystem path (Part A, opacity boundary). */ readonly workspaceLabel: string; /** * Ratification condition #3: a DECLARED, NON-AUTHORITATIVE DISPLAY LIST * ONLY. Self-reported by the agent at registration, and it MUST NEVER be an * input to any authorization decision — authorization stays principal-binding * + server-side scoping (Part B). * * The NAME carries that semantic on purpose (architect ruling, 2026-07-25): * the field is sourced from `H2AActorRegistration.declaredCapabilities` and * never from the authority-bearing `capabilities`, which is the subagent * ceiling and the attestation right. The whole defect this rename closes was * ambiguity between "display list" and "authz list", so the wire field does * not keep the ambiguous name. * * ALLOWLISTED on read: values are intersected with the closed vocabulary * `H2A_DECLARED_CAPABILITIES`, so only known members survive. It is an * intersection, never a denylist — nothing here strips `/home/` or PEM * markers, because a denylist is whack-a-mole and loses to the next encoding. * Anything outside the vocabulary was never a valid declaration and is * dropped silently. */ readonly declaredCapabilities: readonly string[]; /** * ISO 8601 — max heartbeat across this instance's known sessions, or * `'unknown'` when no session carries a parseable heartbeat. NEVER the * registration's mint time: reporting a `createdAt` as "last seen" would be an * unearned freshness claim, the same class the Q1 ruling rejected. */ readonly lastSeen: string; readonly liveness: H2ALivenessState; } /** A session as it may be rendered in a browser. Opaque, non-secret. */ export interface SessionDescriptor { readonly sessionId: string; readonly instanceId: string; readonly topicOrTitle: string; readonly state: "open" | "idle" | "closed"; /** * ISO 8601 — `H2ASession.startedAt`, or `'unknown'` when it is missing or * unparseable. Validated on read for the same reason `role` is: the contract * types this field as ISO 8601, and a presence record written by any other * path must not be able to put arbitrary text in an ISO-typed field. */ readonly openedAt: string; /** * ISO 8601, or `'unknown'` when neither source carries a parseable timestamp. * See {@link SessionDescriptor.activitySource} for provenance. */ readonly lastActivityAt: string; /** * Ratification condition #2: discriminates proven MCP traffic * (`lastMcpActivityAt`, WP-F) from a bare heartbeat fallback, so a consumer * can NEVER present "process alive" as "proven channel activity" by * omission. The gateway/UI MUST render `'heartbeat'` as advisory. */ readonly activitySource: "mcp" | "heartbeat"; /** * Other parties' handles, opacified. Empty for P1 — and empty as an * ESTABLISHED FACT, produced by the explicit branch in * `counterpartsOpaqueRefsFor()` rather than by a literal default: per the * contract's Gaps §2 no h2a structure records "who is this session in contact * with", and negotiations are not mirrored yet (EVO-13 scopes * `h2a_conflict_posture` out until they are). So `[]` here means "there is no * counterpart source at all", never "we did not look". When a derivation lands, the raw `instance:` routing string is * never emitted — the feed server opacifies it with a server-held, * per-principal salt (ratification condition #1) that is stable within a * principal but not enumerable or reversible into a routable bus address. */ readonly counterpartsOpaqueRefs: readonly string[]; } /** * The feed envelope (Part C). `asOf` is what makes staleness checkable by the * consumer: the gateway/UI renders the `liveness`/`state` the feed computed and * never re-derives liveness from wall-clock arithmetic of its own. */ export interface H2AFeedResponse { /** ISO 8601 — the feed's own read timestamp. */ readonly asOf: string; /** * Empty means "we looked and this principal has nothing" — an established * fact, never a default: the builders throw if a source was not read, so a * broken presence read can never surface here as "no agents". A consumer may * therefore trust an empty feed the same way it trusts a populated one. */ readonly instances: readonly InstanceDescriptor[]; /** Empty carries the same established-fact meaning as `instances`. */ readonly sessions: readonly SessionDescriptor[]; } /** Input common to every builder. `asOf` is injected — never `Date.now()`. */ export interface BuildFeedInput { /** The feed's read timestamp, in epoch ms. */ readonly asOf: number; /** * Presence records, e.g. from `listPresence(root)`. **Required to be an * array**: an empty feed must mean "we looked and there is nothing", so a * missing/failed read throws instead of rendering as "no agents". */ readonly sessions: readonly H2ASession[]; /** * Registry rows, e.g. from `store.listInstances()`. **Required, and required * to be an array**: a caller with no registry must pass `[]` DELIBERATELY, * which is a claim ("I looked, the registry is empty"). Omitting it — or * passing the result of a failed read — throws rather than quietly producing a * feed that says the owner has no agents. */ readonly registrations: readonly H2AActorRegistration[]; /** * The mirror push daemon's interval (ms). Only meaningful for MIRRORED rows: * it is what makes `stale` computable (Part C). Absent → no row is `stale`. */ readonly pushIntervalMs?: number; } /** * Session state per the contract's code block. Reuses h2a's existing * primitives — the 90s keepalive window that already gates * `h2a_discover_sessions`, and WP-F connection confidence. Nothing about * freshness is reimplemented here. */ export declare function deriveSessionState(session: H2ASession, asOf: number): "open" | "idle" | "closed"; /** * Liveness per the contract's code block. Same two primitives as * {@link deriveSessionState}, plus the one distinction `state` does not need: * `stale`. * * `stale` is the FEED PIPELINE's own freshness bleeding into a row, not an * agent state. A directly-observed local session (no `mirroredAt`) is NEVER * `stale` — the same-machine clock is trustworthy, so it goes straight to * live/idle/closed. A replicated row is `stale` when the daemon that should * keep refreshing it has gone quiet for more than 2x its push interval: at * that point the numerically "fresh" `heartbeatAt`/`lastMcpActivityAt` carried * in the record can no longer be trusted the way a live local read can, so the * honest label is "we don't know", not "live". */ export declare function deriveLiveness(session: H2ASession, asOf: number, pushIntervalMs?: number): H2ALivenessState; /** * Roll several sessions' liveness up to their instance: best-of, per Part A * ("Best-of across the instance's sessions"). An instance with no session at * all reads `closed` — absence is not liveness. */ export declare function rollUpLiveness(states: readonly H2ALivenessState[]): H2ALivenessState; export interface BuildSessionDescriptorOptions { readonly asOf: number; /** Registration owning this session, when known — used for name fallbacks. */ readonly registration?: H2AActorRegistration | undefined; } /** * One presence record → one `SessionDescriptor`. * * `lastActivityAt` prefers `lastMcpActivityAt` (WP-F: proof the MCP channel * carried real traffic) and falls back to `heartbeatAt`. The fallback is * advisory-only — a live process, not proven channel activity — which is why * `activitySource` is baked into the descriptor rather than left to the * consumer's inference (ratification condition #2). */ export declare function buildSessionDescriptor(session: H2ASession, options: BuildSessionDescriptorOptions): SessionDescriptor; export interface BuildInstanceDescriptorOptions { readonly asOf: number; /** This instance's presence records; any order. */ readonly sessions: readonly H2ASession[]; readonly registration?: H2AActorRegistration | undefined; readonly pushIntervalMs?: number | undefined; } /** * One instance (+ its sessions) → one `InstanceDescriptor`. * * `displayName` order per Part A: the registration's DEC-114 mutable name, * then the most recent LIVE session's host-native name (WP-6), then the * workspace label. `lastSeen` is the max heartbeat across the instance's * sessions. `host` comes from the most recent session's host hint. * * Every chain below matches the contract's declared mapping exactly and adds no * undeclared step: earlier revisions scanned ALL sessions for a label/host and * fell back to `workspace.host` and to `registration.createdAt`, none of which * the spec declares. Code and contract must not disagree, so those were removed * rather than quietly kept. */ export declare function buildInstanceDescriptor(instanceId: string, options: BuildInstanceDescriptorOptions): InstanceDescriptor; /** * Presence + registry → `InstanceDescriptor[]`, **ordered by `lastSeen` * descending** (most recently seen agent first). The order is part of the * behaviour, not an accident of input order, and is pinned by a test — a * documented behaviour that can be deleted with the suite still green is not a * behaviour. Rows whose `lastSeen` is the sentinel sort last. */ export declare function buildInstanceDescriptors(input: BuildFeedInput): InstanceDescriptor[]; /** * Presence + registry → `SessionDescriptor[]`, **ordered by `lastActivityAt` * descending** (most recently active session first). Pinned by a test, for the * same reason as {@link buildInstanceDescriptors}. */ export declare function buildSessionDescriptors(input: BuildFeedInput): SessionDescriptor[]; /** * Presence + registry → the whole `H2AFeedResponse`. * * The caller decides WHICH sessions and registrations to hand in — this * function never widens that set, and (per P1) principal scoping happens * server-side before the call, never here. */ export declare function buildFeedResponse(input: BuildFeedInput): H2AFeedResponse; //# sourceMappingURL=descriptors.d.ts.map