The Operations-owned registry of local Products, plus read-only views of sourced facts about them. Source: src/portfolio.ts (registry, fact streams, views) and src/portfolio-inventory.ts (trusted read-only collectors). It depends only on the accepted journal and canonical-JSON contracts. It does not import the repository-sealing module.
Not implemented here: HTTP, the dashboard, repository sealing/CAS, provider workers, timers, background refresh, product delivery, and admission of delivery Authority. There is no network access, provider invocation, installation, CI or account access. Nothing in a user's Product is mutated.
Boundary
- Registration binds a Product ID to a verified primary Git checkout. It grants metadata inspection only (
operationScope: "metadata-inspection"). Views currently reportdeliveryAuthority: "absent"because no delivery Authority domain is composed here. Registration does not establish whether authority exists elsewhere; future composition must read the authoritative Product gate. - A Product ID is never removed and never re-bound. Branch, HEAD and display-name changes do not affect it.
- Views dispatch nothing, run no Git and execute no command. They work over
CommandJournal.openReadOnly. SQLite may still touch its-wal/-shmcoordination files. - History comes from
readHistory. The pending outbox is not complete history and is never read.
Journal layout
| Aggregate | Holds | Bound |
|---|---|---|
portfolio:local | Registry: Product records, sorted by ID | 100 Products |
portfolio:product:<id> | Fact stream for one Product. State is an index of records (revision, digest, kind, Product); history is every accepted observation. | 2,000 records, 10,000 events |
portfolio:usage | Usage stream shared by all Products, plus a session-attribution index | 2,000 records, 500 sessions, 10,000 events |
The worst-case stream index is about 688 KB, within the journal's 1 MiB document bound. A stream at capacity refuses new records with LIMIT. It never truncates. Long-running use will need a segmentation design first.
API
const portfolio = new Portfolio(journal, { git?: { gitPath?, timeoutMs? }, now?: () => Date });
portfolio.listProducts(): Registry // { version, products }
await portfolio.registerProduct({ commandId, expectedVersion, productId, displayName, root }): RegistrationResult
portfolio.renameProduct({ commandId, expectedVersion, productId, displayName }): RegistrationResult
await portfolio.inspectProduct(productId): InspectionResult // explicit command; records a repository fact
portfolio.recordObservations(productId, observations): ImportResult // 1-100 product facts, atomic
portfolio.recordUsage(receipts): ImportResult // 1-50 usage-receipt/1
portfolio.recordUsageObservations(observations): ImportResult // usage coverage and retractions
portfolio.productView(productId, { window, asOf, staleAfterMs? }): ProductView
portfolio.portfolioView({ window, asOf, staleAfterMs?, after?, limit? }): PortfolioView // limit 1-100, default 20
portfolio.currentConditions({ window, asOf, staleAfterMs? }): ConditionsView // repository, blockers, source notices; each stream isolated
portfolio.blockerRecord(productId, blockerId, { asOf, staleAfterMs? }): BlockerRecordView // governing report and newest 50 revisions
inspectRepository(root, { gitPath?, timeoutMs? }): Promise<RepositoryInspection> // collector
claudeResultReceipt(resultObject, { capturedAt, observedAt, attribution }): UsageReceipt // pure mapping
Errors. PortfolioError.code is one of:
INVALID: malformed input, refused before anything runs or is written.CONFLICT: contradicts what is already recorded.NOT_FOUND: no such Product.LIMIT: a bound would be exceeded.UNAVAILABLE: the root cannot be registered as it is.CORRUPT: a stored record fails re-validation.
Journal errors such as VersionConflictError, CommandConflictError and JournalReadOnlyError pass through unchanged. Receipt errors never quote input values or unsupported field names.
Registration and identity
registerProduct runs the collector itself; the caller cannot supply an identity. The collector's result is RepositoryIdentity:
- canonical
root; commonDirequal to<root>/.git;- device and inode numbers of both directories;
- object format.
The identity has no HEAD, branch, dirty state, worktrees or remotes. bindingDigest is the SHA-256 of that canonical identity.
Idempotence. A replayed command ID returns its stored result (
replayed: true). Re-registering exactly the same binding under a new command ID, even with a staleexpectedVersion, returnsunchangedand writes nothing.Conflicts. Registration is refused (
CONFLICT) when:- the ID is already bound to another repository;
- the same ID is registered with a different name (use
renameProductinstead); - the repository is already bound to another ID. This is detected by root, common directory, or root/common-directory device+inode, so it covers other paths to the same directories.
Refused roots. Symbolic-link aliases, non-canonical paths, linked worktrees, submodules and gitlinks, symbolic-link
.git, a redirectedcore.worktree, and bare repositories are refused withUNAVAILABLE.Inspection results.
inspectProductcompares a fresh inspection with the binding and records one of:available;missing;replaced(different device/inode/format, or the path is now a link or a non-directory);not-repository;not-primary;unreadable.
Nothing is created, repaired or re-bound. HEAD is recorded only when the result is
available.
Collector process boundary
Invocation. A fixed executable (default
/usr/bin/git) with fixed arguments:--no-pager --no-replace-objects --no-optional-locks,core.fsmonitor=false,core.hooksPath=/dev/null,core.untrackedCache=falseandprotocol.allow=never. No shell and no stdin.Environment. Only these variables are passed:
PATH=/usr/bin:/bin,LC_ALL=C;GIT_CONFIG_NOSYSTEM=1,GIT_CONFIG_GLOBAL=/dev/null,GIT_ATTR_NOSYSTEM=1;GIT_OPTIONAL_LOCKS=0,GIT_TERMINAL_PROMPT=0,GIT_NO_REPLACE_OBJECTS=1;GIT_NO_LAZY_FETCH=1,GIT_ALLOW_PROTOCOL=(empty);GIT_CEILING_DIRECTORIES=<parent>.
Inherited
GIT_DIR,GIT_WORK_TREEandGIT_CONFIG_PARAMETERScannot redirect inspection.Commands. Only three:
rev-parse(layout and format),symbolic-ref --quiet HEAD, andrev-parse --verify --quiet HEAD^{commit}. No status, diff, fetch or worktree command runs, so no index refresh, clean/smudge filter, credential helper or promisor/remote helper is reached.Bounds. 5 s timeout by default (100 ms-30 s allowed), then SIGKILL. Stdout is capped at 64 KiB and must be UTF-8; stderr is capped at 8 KiB and discarded. If device or inode numbers change during inspection, the result is
unreadable.What is never read. Source files, remote URLs and credentials. Dirty state is not collected.
Observations
{ collector, recordId, revision, productId, sliceId, observedAt, capturedAt, fact }
observedAtis when the fact happened according to its source. It isnullwhen unknown and is never guessed from filenames or mtimes.capturedAtis when the collector read the fact.- Instants are UTC and normalised to milliseconds.
observedAtcannot be aftercapturedAt, and a declared window cannot end after its capture. - Text is 1-280 printable characters. Control characters, line separators, bidi overrides, BOM and outer whitespace are refused.
- An observation is at most 8,192 UTF-8 bytes. Input must be plain JSON: accessors, prototypes, holes and reserved keys are refused.
Record identity. In a product stream a record is (collector, recordId). In the usage stream it is recordId alone (anthropic/invocation/<id>, anthropic/session-snapshot/<id> or coverage/<name>), so two collectors reporting the same invocation produce one record.
Import rules. The whole batch is atomic.
| Incoming | Result |
|---|---|
| New record | recorded |
Same revision, same content digest (capturedAt excluded; collector also excluded for usage) | duplicate, nothing written |
| Same revision, different content | CONFLICT, whole batch refused |
| Lower revision | obsolete, ignored |
| Higher revision, same kind and Product | corrected: supersedes; history keeps both |
Higher revision of kind retraction | retracted: the record no longer asserts anything |
Changing a record's kind or its Product is CONFLICT. Command IDs are derived from (stream, version, payload), so concurrent or retried identical imports replay rather than record twice. productId must name the stream's own Product; cross-Product mixing is refused. local-git repository facts come only from inspectProduct.
Supported facts
| Kind | Fields | Semantics |
|---|---|---|
activity | entityType, entityId, transition | work: created, integrated, integration-unknown, reverted. proposal: rejected. pull-request: opened, changes-requested, closed-unmerged, merged. issue: opened, closed. Each is counted separately. |
verification | runId, status, currentValidity | For verified, failed or inconclusive (historical Verdicts), validity is valid, invalidated or unknown. For cancelled, cancellation-pending, not-completed or unresolved (not settled, effect unknown), validity is not-applicable. |
release | releaseId, channel (alpha, stable), transition (available, withdrawn) | Availability, kept apart from merges and verification |
measurement | metric, value, unit, window | A measured value for its own window; never summed |
focus | objective or null; the envelope's sliceId | Explicitly stated focus only. Null is an explicit "no focus". |
blocker | blockerId, state, prerequisite, affected[], remediesTried[], nextAction, optional details | Open and resolved states. details is { responsible (owner, factory, external or null), exhaustion, steps[], remedyEvidence[], resolutionEvidence[] }, at most 10 items per list; resolution evidence only on a resolved report. A fact without details keeps its original shape and digest, and reads as details: null (unknown). |
source-coverage | basis, window, status | Basis is work, proposal, pull-request, issue, verification, release or usage. Status is complete, partial, unavailable, unauthenticated or corrupt. |
repository | availability, headRef, headOid | Only from inspectProduct |
usage-invocation, usage-session | See Usage | Usage stream only, created from receipts |
retraction | none | Withdraws a record |
Native journal facts. A trusted collector may import factory Runs as verification facts with, for example, collector factory-journal, recordId execution:<runId>, and revision set to the source aggregate's version. That makes re-imports duplicates and newer versions corrections. The collector must read readHistory or readAggregate, never the outbox. No such collector ships in this module.
Views
Counts. A Count counts distinct entity IDs that made one transition within a half-open UTC window [start, end). It takes one of three states:
exactrequires all of:completecoverage for the count's basis spans the window (union over records and collectors);- no overlapping record reports
partial,unavailable,unauthenticatedorcorrupt; - no relevant undated record.
Zero is only ever exact.
partialgivesatLeasttogether withreasons.unknownmeans nothing establishes a value.
The possible reasons are:
no-complete-coverage,coverage-gap,coverage-ends-before-window-end;source-partial,source-unavailable,source-unauthenticated,source-corrupt;undated-records,unknown-values;recorded-sessions-only,unresolved-session.
A problem declaration stops blocking exactness only when its source corrects that same record to a higher revision. Undated records never enter dated totals.
Current-state facts are focus, repository, blockers, per-Run verification and per-(metric, window) measurement. For each subject, the most recently captured one stands; later journal order breaks ties. Focus and repository use Known<T> with source, observation/capture times and a stale flag (default 24 h); absent facts are unknown. Blockers retain source, capture time and staleness; measurements retain source, capture time and their own window. Verification is projected into counts with explicit validity distinctions. No blocker facts means "none reported", not "none exist". Measurements alone do not establish a Supported customer Outcome; this module has no Outcome assessment contract.
asOf is a capture cutoff, not a historical snapshot:
- facts captured after
asOfare ignored; - each stream is replayed only up to the version its state had when read (
watermark); - a later import may still add facts captured before
asOf; - the registry is read as it is now.
Replay integrity. A replay re-validates every event and must reproduce the stored index exactly; otherwise CORRUPT. portfolioView reports a corrupt Product as { state: "corrupt" } and still returns the other Products.
Usage
Input. The only accepted input is usage-receipt/1, parsed with exact keys at every level:
provider: "anthropic",collector,invocationId,sessionId;observedAt,capturedAt;attribution { productId | null, sliceId | null };invocation(per-invocation counts) ornull;session(session-cumulative snapshot) ornull.
Unknown keys (prompts, completions, thinking, auth) are refused without echoing them.
Counters. There are four disjoint counters: uncached input, cache read, cache creation and output. Thinking tokens are part of output and are not stored separately. null means unknown, never zero. No grand total is ever computed.
Mapping a Claude Code result. claudeResultReceipt maps one final result object:
usagebecomesinvocation;modelUsageandtotal_cost_usdbecomesession, only whensession_idis valid;observedAtcomes from the caller. The result line carries no time, so it staysnullunless a trusted time is supplied.
It reads only allowlisted fields, calls no accessors and retains no completion text. It reads no file. Automatic import of raw transcripts is not implemented.
Invocations. Each is one record, whichever collector reports it, and it belongs to one Product/Slice or stays unallocated. A session's attribution is fixed by its first record. Conflicting attribution is CONFLICT. Invocations dated within the window are counted and their tokens summed. Undated invocations are counted in undated and are never allocated to a window.
Sessions. A session's snapshots resolve only when all three hold:
- every value in every snapshot is known;
- for any two snapshots, one has every field of the other with no smaller value (a model may appear later but may not disappear);
- where both snapshots are dated, the later one is not smaller.
When resolved, the greatest snapshot is the session's total so far. Otherwise the session is unresolved. Snapshots are never added to each other or to invocation figures. Session totals are summed only over recorded sessions, so they are at best partial (recorded-sessions-only). Their time allocation is unknown, so window deltas are not computed.
Cost. Cost is labelled costBasis: "provider-reported-estimate". It is not billing.
Unallocated usage. Usage attributed to no Product appears only in portfolioView().unallocatedUsage.
Unknown sources
Nothing here collects from:
- GitHub PRs and issues;
- releases or store availability;
- telemetry and customer outcomes;
- billing;
- Notion;
- Capability health.
These stay unknown until an explicit, authorised collector records facts and coverage. Local Claude result files have no trustworthy timestamps, so their usage stays undated. Dirty state, remote identities and worktree lists are deliberately not collected.
Known limits
- Device changes. Identity includes device numbers. If a volume is remounted with a different device number, the Product reads as
replaced. This fails closed, but no re-binding command exists yet. The six commissioning repositories are on the internal data volume. - Firmlinks. Detection of other paths such as macOS firmlinks relies on the device+inode comparison.
- Git shim.
/usr/bin/gitis Apple'sxcrunshim, which may use its own cache outside any repository. - Timing. A second inspection within the same millisecond that reaches a different result is refused as a conflict.
- Capacity. The limits in the journal-layout table are explicit and final for this increment.
Commissioning data (verified)
The six Products requested on 2026-09-28 are commissioning data, not application constraints. After independent acceptance, all six were registered and inspected beneath <home directory path>. Registry version 6 and a read-only reopen were verified; HEAD, config and index bytes were unchanged. See commissioning receipt.
| Product ID | Root directory |
|---|---|
justspeaktoit | justspeaktoit |
pasta | pasta |
family-watch | family-watch-activity-tracker |
tally | tally |
focus-sentinel | focus |
software-factory | software-factory |
Registration and inspection read metadata only. They preserve dirty checkouts and grant no UI-test, model, repository-mutation, release, account or spending authority. Focus Sentinel's documented UI-test restriction is unaffected.
Tests
Tests use disposable, test-owned repositories and journals only.
| File | Covers |
|---|---|
test/portfolio-inventory.test.ts | Identity and unchanged bytes; unborn and detached HEAD; malformed roots; missing, alias, nested, worktree, gitlink, core.worktree and bare cases; hostile hooks, fsmonitor, filters, helpers and inherited variables; promisor lazy fetch; timeout, flooding and lying Git; Claude result mapping |
test/portfolio.test.ts | Replay, restart and identical re-registration; alias and worktree duplicates; conflicts; rename; missing, replaced and linked roots never recreated; device change; the 100-Product bound; concurrent processes |
test/portfolio-observations.test.ts | Deduplication across collectors; corrections, retractions, obsolete revisions and conflicts; exact zero versus unknown or partial; stale data; distinct verification outcomes; explicit focus; hostile or oversized input; stream bound; read-only views with no writes or Git; stored corruption |
test/portfolio-usage.test.ts | Cumulative replay and reordering; unresolved sessions; window allocation and coverage; exclusive attribution; strict receipts |
