Factory docs, home
Page navigation

The Operations-owned registry of local Products, each bound to one verified primary Git checkout, plus read-only views of sourced facts and usage about them.

Status: "Accepted for local metadata registration and sourced facts" (build review). Receipts: Portfolio, commissioning (six local Products registered, metadata unchanged). currentConditions, blockerRecord, blocker details and source notices came later, in commit ea718a4, under the Project guidance / Attention review (receipt). Of the nine files the Portfolio receipt pins, src/portfolio.ts has not matched its hash since ea718a4 and docs/portfolio.md since 16a6c34 (the commissioning note); the other seven match. This is accepted local infrastructure, not a Verdict, release or customer benefit, and all 27 epics remain open. Long-form contract: portfolio.md.

  • Source: src/portfolio.ts (registry, fact streams, views), src/portfolio-inventory.ts (trusted read-only collectors)
  • Tests: test/portfolio.test.ts, test/portfolio-inventory.test.ts, test/portfolio-observations.test.ts, test/portfolio-usage.test.ts; currentConditions and blockerRecord are covered in test/attention.test.ts
  • Helpers: test/helpers/portfolio-repos.ts, test/helpers/portfolio-child.ts (one command per process, for concurrency)

Intelligence: none — A registry and replay of sourced facts, including model usage receipts, whose whole value is that nothing in it is inferred.

What it hides

  • Repository identity. The collector runs a fixed Git read-only and derives the identity itself. Callers never supply it. Its parts are the canonical root, <root>/.git, the device and inode of both, and the object format. HEAD is metadata only.
  • Journal bookkeeping. Three aggregate families, content digests, derived import command IDs, bounded retry and a replay check that must reproduce the stored index exactly.
  • Truthful arithmetic. Coverage unions, exact / partial / unknown Counts, staleness, undated records and cumulative session snapshots. Usage is deduplicated globally.
  • Receipt hygiene. A Claude Code result object becomes a usage-receipt/1. Only allowlisted fields are copied, and errors never echo input.

Public interface

Constants:

NameValue
PORTFOLIO_LIMITS (src/portfolio.ts:42, frozen)maxProducts: 100, maxObservationsPerImport: 100, maxReceiptsPerImport: 50, maxObservationBytes: 8_192 (UTF-8), maxRecordsPerStream: 2_000, maxEventsPerStream: 10_000, maxSessions: 500, maxRevision: 1_000, maxPageSize: 100, maxTextLength: 280, maxListItems: 20, maxBlockerDetailItems: 10, maxBlockerRevisions: 50, maxTokenCount: 1_000_000_000_000, defaultStaleAfterMs: 86_400_000
INVENTORY_LIMITS (src/portfolio-inventory.ts:20, frozen)maxRootBytes: 1_024, defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxOutputBytes: 65_536, maxModelsPerReceipt: 16
ACTIVITY_TRANSITIONSwork: created, integrated, integration-unknown, reverted · proposal: rejected · pull-request: opened, changes-requested, closed-unmerged, merged · issue: opened, closed

These bounds are not exported:

  • display name ≤ 80 characters; unit ≤ 32; ≤ 16 models per session snapshot;
  • |measurement value| ≤ 1e15; cost < 1e9;
  • staleAfterMs from 0 to 366 days; default page size 20;
  • import attempts: 8, then CONFLICT; history page: 1,000;
  • Git: /usr/bin/git by default, timeout ≥ 100 ms then SIGKILL, stderr ≤ 8 KiB.

Product IDs match ^[a-z0-9][a-z0-9-]{0,62}$ and collectors ^[a-z][a-z0-9-]{0,31}$. Record, entity, blocker and release IDs match ^[A-Za-z0-9][A-Za-z0-9._:/#@+-]{0,95}$; Slice, Run, session, invocation and snapshot IDs ^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$; command IDs ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$. Instants are UTC, from 2000 to 2999, normalised to milliseconds.

class Portfolio (:486): new Portfolio(journal: CommandJournal, options?: PortfolioOptions), where PortfolioOptions { git?: GitOptions; now?: () => Date }. Git options are not checked until the first inspection.

  • Registry:
    • listProducts(): Registry
    • registerProduct(input: { commandId: string; expectedVersion: number; productId: string; displayName: string; root: string }): Promise<RegistrationResult>. It inspects the root to derive the identity but records only the registry binding: repository stays unknown in views until inspectProduct runs.
    • renameProduct(input: { commandId: string; expectedVersion: number; productId: string; displayName: string }): RegistrationResult
  • Inspection (a write): inspectProduct(productId: string): Promise<InspectionResult>. It runs the collector and records a repository fact under collector local-git, record ID inspection/<capturedAt>.
  • Imports (writes):
    • recordObservations(productId: string, observations: readonly Observation[]): ImportResult takes 1–100 facts.
    • recordUsage(receipts: readonly UsageReceipt[]): ImportResult takes 1–50 receipts. Each receipt gives at most two observations, both at revision 1, and the session snapshot's ID is the receipt's invocationId. So changed content for a recorded invocation or snapshot is CONFLICT while the record stands at revision 1; after a higher-revision retraction any receipt for it is obsolete. Receipts can neither correct nor restore a usage record; only recordUsageObservations can retract one. A receipt with neither invocation nor session is INVALID.
    • recordUsageObservations(observations: readonly Observation[]): ImportResult takes 1–100 facts, only usage source-coverage and retraction.
  • Views (read only):
    • productView(productId: string, query: ViewQuery): ProductView
    • portfolioView(query: ViewQuery & { after?: string; limit?: number }): PortfolioView: in Product ID order; limit is 1–100 (default 20); pass nextAfter as after.
    • currentConditions(query: ViewQuery): ConditionsView: repository, open blockers and source notices for every Product, with no period metrics or usage totals.
    • blockerRecord(productId: string, blockerId: string, query: { asOf: string; staleAfterMs?: number }): BlockerRecordView: the governing report plus at most 50 history entries in reverse journal order (not sorted by capture time), with totalRevisions giving the full count. Only reports captured by asOf count, so a blocker first captured later is NOT_FOUND at that cutoff.

Inventory functions (src/portfolio-inventory.ts):

  • checkRoot(root: unknown): string
  • inspectRepository(root: string, options?: GitOptions): Promise<RepositoryInspection>: GitOptions { gitPath?, timeoutMs? }. It never throws for repository conditions.
  • claudeResultReceipt(line: unknown, context: { capturedAt: string; observedAt: string | null; attribution: UsageReceipt["attribution"] }): UsageReceipt: pure; collector: "claude-code-result"; invocation.model is always null; invocation is null without usage, and session is null unless modelUsage is present and session_id is a UUID. The input must be a plain object with type: "result" and a lowercase UUID uuid. A malformed or negative counter becomes null, but any non-negative safe integer is accepted, even above maxTokenCount, and context is copied unchecked, so a mapped receipt can still be refused by recordUsage.

Errors: PortfolioError (code: PortfolioErrorCode), InventoryInputError and ReceiptError.

Types:

  • Registry:
    • ProductRecord {productId, displayName, identity, bindingDigest, operationScope: "metadata-inspection"}
    • Registry {version, products}
    • RegistrationResult {status: "registered" | "renamed" | "unchanged", productId, registryVersion, replayed}
    • InspectionResult {productId, availability, capturedAt, headRef, headOid}
    • Availability: available | missing | replaced | not-repository | not-primary | unreadable. replaced covers the collector's not-directory and not-canonical and any identity mismatch.
  • Inventory:
    • RepositoryIdentity {root, commonDir, rootDevice, rootInode, commonDirDevice, commonDirInode, objectFormat}; device and inode are decimal strings.
    • ObjectFormat, HeadMetadata {ref, oid}, InspectionStatus (seven values: Availability plus not-directory and not-canonical, without replaced), RepositoryInspection
  • Facts:
    • Observation {collector, recordId, revision, productId, sliceId, observedAt, capturedAt, fact}
    • Fact / FactKind: focus, source-coverage, activity, verification, release, measurement, blocker, repository, usage-invocation, usage-session, retraction
    • ActivityType, ActivityTransition, CountBasis, CoverageStatus, VerificationStatus, Validity
    • TimeWindow {start (inclusive), end (exclusive)}
    • BlockerDetails {responsible, exhaustion, steps, remedyEvidence, resolutionEvidence}, BlockerResponsibility
    • ImportResult {stream, version, recorded, corrected, retracted, duplicates, obsolete}
  • Usage:
    • UsageReceipt (schema: "usage-receipt/1", provider: "anthropic")
    • TokenCounts: four disjoint counters, each number | null
    • SessionModelUsage, SessionModel, TokenCounter
  • Views:
    • Queries and values: ViewQuery {window, asOf, staleAfterMs?}, Count (exact{value} / partial{atLeast, reasons} / unknown{reasons}), Known<T>, SourceRef {collector, recordId, revision, digest}
    • Whole views: ProductView, PortfolioView, ProductSummary = ProductView | CorruptProduct, CorruptProduct {productId, state: "corrupt", detail}, ProductConditions, ConditionsView
    • Blockers and notices: BlockerView, BlockerRevisionView, BlockerRecordView, SourceNoticeView
    • Measurements and usage: MeasurementView, UsageGroup, SessionView

Stored layout:

  • portfolio:local holds the registry (portfolio-registry/1, sorted by ID). Its events are portfolio.product-registered and portfolio.product-renamed.
  • portfolio:product:<productId> and portfolio:usage each hold a portfolio-stream/1 index: records, sessions and an event count. Their events are portfolio.observed {observation, digest, supersedes}.
  • An import's command ID is portfolio-import:<sha256(aggregateId \n version \n canonical payload)>.

Invariants and guarantees

  1. Identity comes from the collector and is never re-bound. bindingDigest is the SHA-256 of the canonical identity. The API has no remove or re-bind. Test: "registration records the collector's identity; replay, restart and identical re-registration never duplicate".

  2. One repository, one Product. decideRegistry (:819) refuses a second binding that matches on root, common directory, root device and inode, or common-directory device and inode. That catches aliases, worktrees and firmlink-style second paths. Every read re-checks the stored registry for duplicate roots and common directories. Test: "aliases, worktrees and second bindings cannot duplicate a Product…".

  3. Registration is idempotent by content.

    • A replayed command ID returns its stored result with replayed: true, provided the payload, aggregate and expectedVersion all match the original; otherwise the journal raises CommandConflictError, so a revised request needs a new command ID.
    • The same binding and name under a new command ID, even with a stale expectedVersion, returns unchanged and writes nothing.
    • The same ID with a new name is CONFLICT: use renameProduct.
  4. Inspection never creates, repairs or re-binds (availabilityOf, :913).

    • An identity mismatch, a non-directory or a non-canonical path is recorded as replaced.
    • HEAD is recorded only when the checkout is available.
    • Tests: "missing or replaced roots are recorded as unavailable and never recreated or re-bound"; "a repository on another device at the same path and inode is not the registered one".
  5. The collector is read-only and bounded.

    • Fixed GIT_PREFIX flags and an explicit environment: no system or global config, no lazy fetch, protocol.allow=never, ceiling at the parent.
    • Only three commands run: rev-parse for the layout, symbolic-ref --quiet HEAD and rev-parse --verify --quiet HEAD^{commit}.
    • Device and inode are compared before and after; a change gives unreadable.
    • Inventory tests cover hostile configuration, hooks, promisor remotes, flooding, lying Git and unchanged bytes.
  6. Imports are atomic and idempotent (plan, :983). The whole batch is classified before anything is written:

    • an exact repeat is a duplicate, and a lower revision is obsolete. A repeat that changes only capturedAt (or, for usage, the collector) is still a duplicate: it refreshes neither the stored capture time nor staleness, so refreshing a fact needs a higher revision;
    • a higher revision corrects the record without erasing its history, but its kind and Product are fixed;
    • the same revision with other content, or a retraction of an unknown record, is CONFLICT for the whole batch.

    Derived command IDs make concurrent identical imports record once. Tests: "the same entity imported twice or by two collectors counts once…"; "concurrent processes…".

  7. Source rules. Only inspectProduct records repository facts, and only under collector local-git. An observation's productId must name its stream's Product. Usage coverage lives only in the usage stream and needs basis: "usage", productId: null and sliceId: null: it is portfolio-wide, governing every Product, Slice and unallocated usage group alike (so one complete declaration can make another Product's usage an exact 0), and per-Product or per-Slice usage coverage is unsupported. observedAt is never after capturedAt, and a coverage or measurement window never ends after its capture.

  8. Usage is attributed once, globally.

    • A usage record key is the recordId alone (anthropic/invocation/<id>, anthropic/session-snapshot/<id> or coverage/<name>). Its digest excludes collector and capturedAt, so two collectors of one invocation make one record.
    • The first record fixes a session's (Product, Slice).
    • Tests: "one invocation is one record whichever collector reports it…"; "usage can never move between Products".
  9. Unknown is never zero.

    • Coverage: an entity Count is exact only when current complete coverage for its basis spans [start, end), no overlapping declaration reports trouble and no undated entity key is left without a dated in-window report of that same key (coverageOf, :1540; countKeys, :1564). An entity count of 0 is only ever exact.
    • Sums: sumCount (:1777) is exact only when the population is exact and every value is known. Otherwise it is a lower bound (possibly atLeast: 0) or unknown.
    • Sessions: totals are never exact (recorded-sessions-only), and snapshots are never added together. sessionView (:1794) leaves a session unresolved unless every value is known and its snapshots form one non-decreasing sequence in which a dated later snapshot is never smaller.
  10. Views read only. They run no command, no Git and no journal write, and they never read the pending outbox. They work on CommandJournal.openReadOnly. Test: "views read only: no command, no journal write and no Git, even when the repository has gone".

  11. Replay integrity. readStream (:1128) re-validates every event up to the version it read: digest, revision order and supersedes. The replay must reproduce the stored index exactly, or the result is CORRUPT. Nothing is repaired or skipped. Test: "stored corruption is reported, never repaired or skipped".

  12. Input and storage bounds refuse and never truncate. LIMIT applies at 100 Products, 2,000 records, 10,000 events or 500 sessions. The one deliberate cut is on output: blockerRecord returns at most 50 history entries and reports the full count. Tests: "the registry holds at most its bound of Products"; "a stream is bounded, and its worst-case index stays within the journal's document bound".

  13. Stated, never inferred. Focus is only an explicit focus fact. A Verdict (verified, failed, inconclusive) is kept apart from its current validity; the other statuses take not-applicable. A blocker is resolved only when its source says so, and evidence references are stored as text, never fetched or verified. Product, conditions and blocker-record views are deep-frozen. portfolioView is shallow-frozen: its window object is frozen only once a Product view on the page has been built, so it can stay mutable on an empty or all-corrupt page.

  14. Current state is the latest capture. currentFacts (:1491) takes each record's highest revision captured by asOf and drops retracted records; latestBy (:1508) then keeps the most recently captured report per subject, later journal sequence breaking ties. Repository, focus and blockers ignore the query window. Source notices need an explicit non-complete coverage declaration overlapping it; measurements keep their own overlapping windows and are never summed.

Failure semantics

PortfolioError.codeWhen
INVALIDMalformed input or a bad root string, refused before anything runs or is written; also an empty batch, or a constructor argument that is not a CommandJournal
CONFLICTContradicts the record: an ID bound elsewhere, a repository bound under another ID, a rename through registerProduct, the same revision with other content, a kind or Product change, a session re-attribution, a retraction of an unknown record, two inspections in one millisecond with different results, or an import still losing races after 8 attempts
NOT_FOUNDProduct not registered; blockerRecord for a blocker no source reported by asOf
LIMITCapacity or cardinality: 100 Products, 2,000 records, 10,000 events, 500 sessions, an oversized batch or list, or an observation over 8,192 canonical UTF-8 bytes once it has passed JSON validation. An out-of-range page size, revision, token count, text length, cost or staleAfterMs is INVALID, and so is input beyond the inherited canonical-JSON bounds (1,048,576 bytes, depth 64)
UNAVAILABLEregisterProduct inspected a root that is not available; the message carries the status. Nothing is written.
CORRUPTThe stored registry, stream, event or stored result fails re-validation
  • Journal errors pass through unchanged. VersionConflictError comes from a stale expectedVersion on renameProduct, or on registerProduct unless exactly that binding and name are already recorded. CommandConflictError comes from reusing a command ID with a different payload, aggregate or expectedVersion. JournalReadOnlyError comes from a write on a read-only journal.
  • Other error classes. Invalid git options surface as InventoryInputError from registerProduct or inspectProduct. ReceiptError comes only from claudeResultReceipt. Receipt validation errors never echo unsupported field names or raw prompt, completion or authentication content; they may name supported schema fields such as uuid or result.modelUsage, and import conflict or attribution errors may include validated record, session and Product IDs.
  • Unknown versus failed. An unreadable, missing or replaced inspection is a recorded fact, not an error. Missing coverage, undated records or null counters become partial or unknown with reasons, never an exact 0; a partial sum of known zero counters may report atLeast: 0 with its reasons.
  • Corruption isolation.
    • currentConditions isolates CORRUPT for each Product stream and for the usage stream.
    • portfolioView isolates only a corrupt Product stream; a corrupt usage stream throws.
    • productView and blockerRecord throw.
    • A corrupt registry throws from every read, including currentConditions and listProducts.
    • Only PortfolioError CORRUPT is isolated. Journal errors, including the StorageError that readAggregate raises for malformed stored JSON, propagate and abort the whole view.
  • Retries. Imports retry internally on version races. Any caller retry of identical content is safe and writes nothing: it is a duplicate, an obsolete observation or a journal replay. registerProduct always re-inspects before it executes, so replaying it against a root that has since become unavailable raises UNAVAILABLE instead of returning the stored result.

Trust scope

Established locally:

  • Metadata-only registration of primary checkouts on one machine.
  • A collector hardened against hostile config, hooks, filters, helpers, inherited GIT_* variables and promisor fetch.
  • Idempotent identities, bounded correction history, explicit coverage, globally deduplicated usage and read-only projections.
  • Concurrent processes sharing one journal, and stored corruption detected on read.
  • Evidence:
    • the acceptance check recorded 266/266 tests (receipt);
    • the later full check recorded 350/350 (build review);
    • six local Products were registered with HEAD, config and index unchanged, verified by a read-only reopen (commissioning).

Not established:

  • Authority. No delivery Authority. deliveryAuthority: "absent" means none is recorded here, not that none exists elsewhere.
  • Collectors.
    • Imports are explicit only: no native Run, GitHub, release, telemetry, billing or Notion collectors, and no background refresh. Those sources stay unknown until explicit imported facts and coverage establish partial or exact values.
    • A collector name is a caller-chosen label, not an authenticated identity. Anyone with journal write access can import facts under any name except local-git.
  • Retention. Stream retention is bounded, with no automatic segmentation.
  • Cost. Reported cost is a provider estimate and a lower bound for recorded sessions. It is not billing and is never allocated to a window.
  • Time. asOf is a capture cutoff, not a historical snapshot. The registry is always read as it is now.
  • Identity edge cases. Inspection compares identity fields, not mount events: a remount that changes a device number reads as replaced, and no re-bind command exists. Firmlink detection relies on device and inode. /usr/bin/git is Apple's xcrun shim, which may keep its own cache outside any repository.
  • Not in this component. No HTTP and no dashboard. Measurements alone establish no Supported customer Outcome.

Composition

  • Depends on:

    • src/journal.ts (Command journal): CommandJournal.execute, readAggregate, readHistory, canonicalJson, DecisionError, InvalidInputError and VersionConflictError;
    • src/canonical-json.ts: jsonObjectEntries and parseJson;
    • src/portfolio-inventory.ts, which uses Node builtins only.

    It does not import src/repository.ts.

  • Used by:

    • src/dashboard-server.ts (Dashboard):
      • readAll → portfolioView (pages of 100); attentionResponse → currentConditions; productResponse → productView; blockerResponse → blockerRecord;
      • isRegistered, guidanceResponse and guidanceRevisionResponse → listProducts;
      • the Portfolio responses read through withReadOnlyPortfolio; the guidance responses use withReadOnlyJournal and construct Portfolio directly; both open with openReadOnly;
      • the only call to a Portfolio write in src/ is inspectCommand → inspectProduct, gated by allowInspect and run on CommandJournal.openExisting. The server's other command, guidanceCommand, writes through ProductGuidance, not the Portfolio.
    • src/dashboard-view.ts: projectPortfolio and projectProduct consume PortfolioView, ProductView and UsageGroup; productDto copies fields one by one.
    • src/attention.ts (Required actions and blockers): types only; projectAttention over ConditionsView and projectBlockerDetail over BlockerRecordView.
    • src/project-guidance.ts (Project guidance): ProductGuidance#registered → listProducts.
  • No caller in src/. registerProduct, renameProduct, recordObservations, recordUsage, recordUsageObservations and claudeResultReceipt have no caller in src/. Commissioning invoked them outside src/; the evidence paths are recorded in the receipts.

Changing it safely

  • Run: node --test test/portfolio*.test.ts test/attention.test.ts, then npm run typecheck, then npm run check before acceptance. Tests use disposable, test-owned repositories and journals.

  • Which tests prove what:

    • portfolio.test.ts: identity, replay, restart, aliases, conflicts, rename, missing or replaced roots, device change, the 100-Product bound and concurrent processes.
    • portfolio-inventory.test.ts: collector read-only behaviour, refused locations, hostile Git, bounds, and the Claude result mapping.
    • portfolio-observations.test.ts: dedup, corrections, coverage and exactness, verification, focus, hostile input, stream bounds, read-only views and corruption.
    • portfolio-usage.test.ts: session snapshots, attribution, window allocation and strict receipts.
    • attention.test.ts: currentConditions isolation, blockerRecord and blocker details.
  • Stored shapes are protocol. These define digests and replay of existing journals:

    • digestOf (:978), recordKey (:966) and canonical JSON;
    • Fact field sets;
    • portfolio-registry/1 and portfolio-stream/1.

    A silent change turns stored history into CORRUPT. New fact fields must be optional and absent from old digests, as blocker details is. Otherwise introduce a new schema.

  • Consumers. Changing a view shape means changing dashboard-view.ts, attention.ts and the dashboard contract deliberately.

  • Receipts. The Portfolio receipt pins nine file hashes; src/portfolio.ts (since ea718a4) and docs/portfolio.md (since 16a6c34) no longer match. A behaviour change needs a fresh independent review and a new receipt. Also update portfolio.md and the build review row. Never rewrite hashes in an existing receipt.

  • Reviewers check:

    • views still write nothing and run no Git;
    • identity still comes only from the collector;
    • no new Git subcommand, flag or environment entry reaches config, hooks, helpers or the network;
    • validation still precedes writes;
    • bounds still refuse rather than truncate;
    • unknown or partial is never collapsed to 0;
    • errors never echo unsupported receipt fields or raw receipt content; only validated IDs may appear.

Source: docs/agents/portfolio.md