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;currentConditionsandblockerRecordare covered intest/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
resultobject becomes ausage-receipt/1. Only allowlisted fields are copied, and errors never echo input.
Public interface
Constants:
| Name | Value |
|---|---|
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_TRANSITIONS | work: 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;staleAfterMsfrom 0 to 366 days; default page size 20;- import attempts: 8, then
CONFLICT; history page: 1,000; - Git:
/usr/bin/gitby 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(): RegistryregisterProduct(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:repositorystaysunknownin views untilinspectProductruns.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 arepositoryfact under collectorlocal-git, record IDinspection/<capturedAt>. - Imports (writes):
recordObservations(productId: string, observations: readonly Observation[]): ImportResulttakes 1–100 facts.recordUsage(receipts: readonly UsageReceipt[]): ImportResulttakes 1–50 receipts. Each receipt gives at most two observations, both at revision 1, and the session snapshot's ID is the receipt'sinvocationId. So changed content for a recorded invocation or snapshot isCONFLICTwhile the record stands at revision 1; after a higher-revision retraction any receipt for it isobsolete. Receipts can neither correct nor restore a usage record; onlyrecordUsageObservationscan retract one. A receipt with neitherinvocationnorsessionisINVALID.recordUsageObservations(observations: readonly Observation[]): ImportResulttakes 1–100 facts, only usagesource-coverageandretraction.
- Views (read only):
productView(productId: string, query: ViewQuery): ProductViewportfolioView(query: ViewQuery & { after?: string; limit?: number }): PortfolioView: in Product ID order;limitis 1–100 (default 20); passnextAfterasafter.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), withtotalRevisionsgiving the full count. Only reports captured byasOfcount, so a blocker first captured later isNOT_FOUNDat that cutoff.
Inventory functions (src/portfolio-inventory.ts):
checkRoot(root: unknown): stringinspectRepository(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.modelis alwaysnull;invocationisnullwithoutusage, andsessionisnullunlessmodelUsageis present andsession_idis a UUID. The input must be a plain object withtype: "result"and a lowercase UUIDuuid. A malformed or negative counter becomesnull, but any non-negative safe integer is accepted, even abovemaxTokenCount, andcontextis copied unchecked, so a mapped receipt can still be refused byrecordUsage.
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.replacedcovers the collector'snot-directoryandnot-canonicaland 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:Availabilityplusnot-directoryandnot-canonical, withoutreplaced),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,retractionActivityType,ActivityTransition,CountBasis,CoverageStatus,VerificationStatus,ValidityTimeWindow {start (inclusive), end (exclusive)}BlockerDetails {responsible, exhaustion, steps, remedyEvidence, resolutionEvidence},BlockerResponsibilityImportResult {stream, version, recorded, corrected, retracted, duplicates, obsolete}
- Usage:
UsageReceipt(schema: "usage-receipt/1",provider: "anthropic")TokenCounts: four disjoint counters, eachnumber | nullSessionModelUsage,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
- Queries and values:
Stored layout:
portfolio:localholds the registry (portfolio-registry/1, sorted by ID). Its events areportfolio.product-registeredandportfolio.product-renamed.portfolio:product:<productId>andportfolio:usageeach hold aportfolio-stream/1index: records, sessions and an event count. Their events areportfolio.observed {observation, digest, supersedes}.- An import's command ID is
portfolio-import:<sha256(aggregateId \n version \n canonical payload)>.
Invariants and guarantees
Identity comes from the collector and is never re-bound.
bindingDigestis 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".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…".Registration is idempotent by content.
- A replayed command ID returns its stored result with
replayed: true, provided the payload, aggregate andexpectedVersionall match the original; otherwise the journal raisesCommandConflictError, so a revised request needs a new command ID. - The same binding and name under a new command ID, even with a stale
expectedVersion, returnsunchangedand writes nothing. - The same ID with a new name is
CONFLICT: userenameProduct.
- A replayed command ID returns its stored result with
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".
- An identity mismatch, a non-directory or a non-canonical path is recorded as
The collector is read-only and bounded.
- Fixed
GIT_PREFIXflags and an explicit environment: no system or global config, no lazy fetch,protocol.allow=never, ceiling at the parent. - Only three commands run:
rev-parsefor the layout,symbolic-ref --quiet HEADandrev-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.
- Fixed
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
CONFLICTfor 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…".
- an exact repeat is a duplicate, and a lower revision is obsolete. A repeat that changes only
Source rules. Only
inspectProductrecordsrepositoryfacts, and only under collectorlocal-git. An observation'sproductIdmust name its stream's Product. Usage coverage lives only in the usage stream and needsbasis: "usage",productId: nullandsliceId: null: it is portfolio-wide, governing every Product, Slice and unallocated usage group alike (so onecompletedeclaration can make another Product's usage an exact 0), and per-Product or per-Slice usage coverage is unsupported.observedAtis never aftercapturedAt, and a coverage or measurement window never ends after its capture.Usage is attributed once, globally.
- A usage record key is the
recordIdalone (anthropic/invocation/<id>,anthropic/session-snapshot/<id>orcoverage/<name>). Its digest excludescollectorandcapturedAt, 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".
- A usage record key is the
Unknown is never zero.
- Coverage: an entity
Countisexactonly when currentcompletecoverage 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 everexact. - Sums:
sumCount(:1777) is exact only when the population is exact and every value is known. Otherwise it is a lower bound (possiblyatLeast: 0) orunknown. - Sessions: totals are never exact (
recorded-sessions-only), and snapshots are never added together.sessionView(:1794) leaves a sessionunresolvedunless every value is known and its snapshots form one non-decreasing sequence in which a dated later snapshot is never smaller.
- Coverage: an entity
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".Replay integrity.
readStream(:1128) re-validates every event up to the version it read: digest, revision order andsupersedes. The replay must reproduce the stored index exactly, or the result isCORRUPT. Nothing is repaired or skipped. Test: "stored corruption is reported, never repaired or skipped".Input and storage bounds refuse and never truncate.
LIMITapplies at 100 Products, 2,000 records, 10,000 events or 500 sessions. The one deliberate cut is on output:blockerRecordreturns 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".Stated, never inferred. Focus is only an explicit
focusfact. A Verdict (verified,failed,inconclusive) is kept apart from its current validity; the other statuses takenot-applicable. A blocker isresolvedonly 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.portfolioViewis shallow-frozen: itswindowobject 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.Current state is the latest capture.
currentFacts(:1491) takes each record's highest revision captured byasOfand 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-completecoverage declaration overlapping it; measurements keep their own overlapping windows and are never summed.
Failure semantics
PortfolioError.code | When |
|---|---|
INVALID | Malformed 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 |
CONFLICT | Contradicts 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_FOUND | Product not registered; blockerRecord for a blocker no source reported by asOf |
LIMIT | Capacity 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) |
UNAVAILABLE | registerProduct inspected a root that is not available; the message carries the status. Nothing is written. |
CORRUPT | The stored registry, stream, event or stored result fails re-validation |
- Journal errors pass through unchanged.
VersionConflictErrorcomes from a staleexpectedVersiononrenameProduct, or onregisterProductunless exactly that binding and name are already recorded.CommandConflictErrorcomes from reusing a command ID with a different payload, aggregate orexpectedVersion.JournalReadOnlyErrorcomes from a write on a read-only journal. - Other error classes. Invalid
gitoptions surface asInventoryInputErrorfromregisterProductorinspectProduct.ReceiptErrorcomes only fromclaudeResultReceipt. Receipt validation errors never echo unsupported field names or raw prompt, completion or authentication content; they may name supported schema fields such asuuidorresult.modelUsage, and import conflict or attribution errors may include validated record, session and Product IDs. - Unknown versus failed. An
unreadable,missingorreplacedinspection is a recorded fact, not an error. Missing coverage, undated records or null counters becomepartialorunknownwithreasons, never an exact 0; a partial sum of known zero counters may reportatLeast: 0with its reasons. - Corruption isolation.
currentConditionsisolatesCORRUPTfor each Product stream and for the usage stream.portfolioViewisolates only a corrupt Product stream; a corrupt usage stream throws.productViewandblockerRecordthrow.- A corrupt registry throws from every read, including
currentConditionsandlistProducts. - Only
PortfolioErrorCORRUPTis isolated. Journal errors, including theStorageErrorthatreadAggregateraises 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.
registerProductalways re-inspects before it executes, so replaying it against a root that has since become unavailable raisesUNAVAILABLEinstead 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
unknownuntil 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.
- Imports are explicit only: no native Run, GitHub, release, telemetry, billing or Notion collectors, and no background refresh. Those sources stay
- 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.
asOfis 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/gitis Apple'sxcrunshim, 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,InvalidInputErrorandVersionConflictError;src/canonical-json.ts:jsonObjectEntriesandparseJson;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,guidanceResponseandguidanceRevisionResponse→listProducts;- the Portfolio responses read through
withReadOnlyPortfolio; the guidance responses usewithReadOnlyJournaland constructPortfoliodirectly; both open withopenReadOnly; - the only call to a Portfolio write in
src/isinspectCommand→inspectProduct, gated byallowInspectand run onCommandJournal.openExisting. The server's other command,guidanceCommand, writes throughProductGuidance, not the Portfolio.
src/dashboard-view.ts:projectPortfolioandprojectProductconsumePortfolioView,ProductViewandUsageGroup;productDtocopies fields one by one.src/attention.ts(Required actions and blockers): types only;projectAttentionoverConditionsViewandprojectBlockerDetailoverBlockerRecordView.src/project-guidance.ts(Project guidance):ProductGuidance#registered→listProducts.
No caller in
src/.registerProduct,renameProduct,recordObservations,recordUsage,recordUsageObservationsandclaudeResultReceipthave no caller insrc/. Commissioning invoked them outsidesrc/; the evidence paths are recorded in the receipts.
Changing it safely
Run:
node --test test/portfolio*.test.ts test/attention.test.ts, thennpm run typecheck, thennpm run checkbefore 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:currentConditionsisolation,blockerRecordand blockerdetails.
Stored shapes are protocol. These define digests and replay of existing journals:
digestOf(:978),recordKey(:966) and canonical JSON;Factfield sets;portfolio-registry/1andportfolio-stream/1.
A silent change turns stored history into
CORRUPT. New fact fields must be optional and absent from old digests, as blockerdetailsis. Otherwise introduce a new schema.Consumers. Changing a view shape means changing
dashboard-view.ts,attention.tsand the dashboard contract deliberately.Receipts. The Portfolio receipt pins nine file hashes;
src/portfolio.ts(since ea718a4) anddocs/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.
