Factory docs, home
Page navigation

A pure, stateless projection of the Portfolio's current sourced conditions into one attention list (actions and notices), plus one blocker's resolution detail, served by the dashboard API.

Status: build review row "Project guidance / Attention": "Accepted local domain and browser interface" (build review). Receipt: disposition accepted-local, scope "Local project guidance, source-derived attention and blocker resolution views", base 8ada02a. Behaviour: dashboard: Attention, blocker resolution guide, human interface. This is accepted local infrastructure, not a Verdict, release or customer benefit; all 27 epics remain open. Study: none yet (no plate position in module studies).

  • Source: src/attention.ts. DTO types: src/dashboard-contract.ts (Attention section, :376-490; BlockerDto and BlockerDetailsDto, :54-80).
  • Browser: apps/dashboard/src/attention-list.tsx, apps/dashboard/src/views/ActionsPage.tsx, apps/dashboard/src/views/BlockerPage.tsx; words in apps/dashboard/src/format.ts.
  • Tests: test/attention.test.ts (8), test/dashboard-guidance-attention.test.ts (3 of its 6 are attention routes); also test/dashboard-boundaries.test.ts (imports) and test/dashboard-editor.test.ts (attention words).

Intelligence: none — Items exist exactly while sourced facts say so; inferred urgency or drafted advice would be a second, unsourced queue.

What it hides

  • Classification. Which sourced condition is an action and which a notice, and who a source says can act (owner, factory, external or unknown), including the strict owner-required rule.
  • Identity and grouping. Stable item IDs (Product, subject, reason), grouping of repeated source-coverage declarations, and one deterministic order.
  • Isolation of damage. Portfolio.currentConditions reads each Product stream and the usage stream separately; this turns each unreadable one into one facts-unreadable action, so a damaged stream never fails the page. A damaged registry or an unreadable journal still does (see failure semantics).
  • Resolution standing. A resolved report stays reported or reported-with-evidence, never verified. Explicit top-level DTO copies mean a new top-level Portfolio field is not served until chosen here.

Public interface

All from src/attention.ts. Pure: no I/O, no clock, no journal, no Git, and it throws nothing of its own.

Inputs

  • type AttentionRead = Pick<ConditionsView, "products" | "registeredProducts" | "usage"> (:36): normally Portfolio.currentConditions(query). Each product is ProductConditions | CorruptProduct; a full ProductView also fits. usage is { state: "ok", sourceNotices } or { state: "corrupt", detail }.
  • interface AttentionOptions { readonly canCheckRepository: boolean } (:38): whether this server can run the repository availability check.

List

  • projectAttention(read: AttentionRead, meta: Omit<ViewMeta, "protocol">, options: AttentionOptions): AttentionDto (:43) returns { protocol: DASHBOARD_PROTOCOL, ...meta, registeredProducts, counts, items }.
  • attentionItems(read: AttentionRead, options: AttentionOptions): AttentionItemDto[] (:53), sorted.
  • attentionCounts(items: readonly AttentionItemDto[]): AttentionCountsDto (:48) returns { actions, ownerRequired, notices }; ownerRequired counts actions only.

Blocker detail

  • projectBlockerDetail(record: BlockerRecordView, staleAfterMs: number): BlockerDetailDto (:189), normally over Portfolio.blockerRecord(productId, blockerId, { asOf, staleAfterMs }). Result: { protocol, asOf, staleAfterMs, productId, displayName, blockerId, streamVersion, current, history, totalRevisions, recheck: "unsupported" }. current is BlockerDto & { state: "open" | "resolved", observedAt, resolution, responsible, ownerRequired }, or null when no current report carries this blockerId (every record retracted, or corrected to another ID). Each history entry (reverse journal order, capped at 50) is { state: "open" | "resolved" | "retracted", report, source, observedAt, capturedAt }, with report: null for a retraction.
  • blockerDto(blocker: BlockerView): BlockerDto (:224).
  • blockerReportDto(blocker: Omit<BlockerView, "source" | "capturedAt" | "stale">): Omit<BlockerDto, "source" | "capturedAt" | "stale"> (:228): { blockerId, prerequisite, affected, remediesTried, nextAction, details }.

Constants and limits

NameValueWhere
MAX_WINDOWS5 windows per grouped source itemprivate, :31
MAX_DETAIL_LENGTH280; a longer facts-unreadable detail becomes its first 279 UTF-16 units + …private, :32
PORTFOLIO_SCOPE"*": the ID scope of usage items (no Product)private, :33
DASHBOARD_PROTOCOL"factory-dashboard/3"contract
Blocker ID/^[A-Za-z0-9][A-Za-z0-9._:\/#@+-]{0,95}$/ (Portfolio's record-ID pattern; the server's BLOCKER_ID is identical)portfolio, server
PORTFOLIO_LIMITS.maxTextLength280: prerequisite, nextAction, exhaustion and every list entry except affected are 1–280 characters of trimmed, printable textportfolio
PORTFOLIO_LIMITS.maxListItems20 each for affected (record IDs, same pattern as above) and remediesTriedportfolio
PORTFOLIO_LIMITS.maxBlockerDetailItems10 each for steps, remedyEvidence, resolutionEvidenceportfolio
PORTFOLIO_LIMITS.maxBlockerRevisions50 history entries; totalRevisions gives the full countportfolio
PORTFOLIO_LIMITS.defaultStaleAfterMs86_400_000 (one day); the server always passes thisportfolio
PORTFOLIO_LIMITS.maxObservationBytes8_192 canonical JSON bytes per observation, so the per-field maxima above do not all fit at onceportfolio

Blocker facts as recorded (Portfolio.recordObservations is the only way a blocker reaches this module; src/portfolio.ts:1274-1286 and :1330-1343): a blocker fact has exactly kind, blockerId, state ("open" or "resolved"), prerequisite, affected, remediesTried and nextAction, plus optionally details. details may be omitted (the pre-details shape keeps its digest) but not null; when present it has exactly responsible (owner, factory, external or null), exhaustion (text or null), steps, remedyEvidence and resolutionEvidence, and an open blocker's resolutionEvidence must be empty. Wrong shape or content is INVALID; a list or observation over its limit is LIMIT (test "blocker details are optional…"). Every observation must name the stream's Product (INVALID otherwise). A higher revision of a collector record is a correction: it may retract the record, and otherwise must keep the kind blocker (CONFLICT on a change); nothing stops it changing blockerId (permitted by src/portfolio.ts:998-1009, exercised by no test).

Items (AttentionItemDto; every item has id, productId, productName, category, responsible, ownerRequired, target, check, kind. responsible is "unknown" and ownerRequired is false unless stated.)

kindCategoryidtargetExtra fields
facts-unreadableaction<productId>/stored-facts/unreadable or */stored-facts/usage; productName: nullproject, or { kind: "portfolio" }detail (bounded)
blockeraction; responsible from details<productId>/blocker/<blockerId>{ kind: "blocker", productId, blockerId }blockerId, prerequisite, affected, nextAction, source, capturedAt, stale. Not remediesTried or details: those are in the blocker detail and in blockers.open of the Product and Portfolio DTOs
repository-unavailableaction<productId>/repository/unavailableprojectavailability, source, capturedAt, stale
source-problem (unavailable, unauthenticated, corrupt)action<productId or *>/source/<basis>/<status>project or portfoliobasis, status, windows, records, source, capturedAt
repository-uncheckednotice<productId>/repository/uncheckedprojectnone
repository-stalenotice<productId>/repository/staleprojectsource, capturedAt (no stale flag; the kind says so)
source-partialnotice<productId or *>/source/<basis>/partialproject or portfolioas source-problem

check is "repository-availability" on the three repository kinds only when canCheckRepository, else null; it is null on every other kind, since no collector checks a blocker prerequisite or reconnects a source.

Invariants and guarantees

  1. Pure and stateless. It imports only ./dashboard-contract.ts and types from ./portfolio.ts (boundaries test "the projections are pure…"). There is no queue, read or dismiss flag, notification or approval step. An item exists exactly while its facts say so.
  2. Nothing is inferred. Only open blockers, observed repository state, declared coverage trouble and unreadable streams make items. Empty guidance, a focus with no objective, dirty files, failed or unresolved verification, complete coverage and zero counts make none (test "nothing infers a human gate").
  3. Owner rule. ownerRequired is true iff details.responsible === "owner" && details.exhaustion !== null (:179). The detail view also requires state === "open". Missing details or a null responsible read as "unknown" (test "open blockers are actions with honest responsibility").
  4. One condition, one item. An item's ID is Product, subject and reason, so re-observation and correction update it rather than adding one. Portfolio first keeps each collector record's latest revision captured by asOf (currentFacts: journal order, whatever its capturedAt; a retracted record drops out), then latestBy keeps one governing report per blockerId across the surviving records: the greatest capturedAt wins, later journal order breaking ties. The item leaves when the governing report says resolved. Between records, an older resolved report does not override a newer open one; within one record, the latest revision governs even if captured earlier. Any imported source may record the resolution, not only the original collector (test "one condition is one item"). A correction that changes a record's blockerId moves that record's contribution to the new ID; the old ID stays current if another record still reports it.
  5. Periods and cutoff. Blockers and repository state are current and ignore the period: a 60-day-old open blocker appears in 24h, 7d and 90d, marked stale (test "current blockers ignore the period…"; server test "current blockers across periods"). Source notices arrive from Portfolio already limited to those overlapping the window. asOf is a capture cutoff: a report captured after it is not in the read.
  6. Source grouping. Notices group per scope by basis/status. records counts all of them, and windows keeps the first five in Portfolio's order (window start, then end, then collector and record). source and capturedAt come from the most recently captured declaration. partial is a notice; unavailable, unauthenticated and corrupt are actions.
  7. Repository. An unknown reading is repository-unchecked. Any availability other than available is repository-unavailable, whatever its age. available but stale is repository-stale; available and fresh makes no item (test "repository items").
  8. Isolation. A CorruptProduct, or usage.state === "corrupt", is one facts-unreadable action, and every other stream is still projected (test "a corrupt Product or usage stream is one action…"; server test "Actions stay readable when the shared usage stream is corrupt").
  9. Deterministic order (compare, :164): actions before notices, then Product (portfolio-wide last), product ID as a string, KIND_ORDER (facts-unreadable, blocker, repository-unavailable, source-problem, repository-unchecked, repository-stale, source-partial), then id. It implies no business priority.
  10. Resolution is a source's report. resolution is null while open. When resolved it is reported-with-evidence if resolutionEvidence is non-empty, else reported. current is null when no current report carries the ID: history keeps a retraction as state: "retracted" with report: null, while a record corrected to another blockerId adds no entry under the old ID, whose earlier reports remain. Neither is a resolution. history is in reverse journal order (what the contract calls newest first), capped at 50: not necessarily descending capturedAt, and the governing report need not be its first entry. recheck is always "unsupported" (test "blocker detail: resolution is a source's report…").
  11. Explicit copies. blockerDto, blockerReportDto and the private detailsDto select named top-level fields only. Nested source, arrays and windows are reused by reference from Portfolio's parsed, frozen views; this is not a recursive filter.
  12. Source text is data. The browser renders it as text (boundaries test "the browser renders source and user text only as text"), and the server's JSON bodies escape <, > and & (server test on a hostile prerequisite). Evidence references are never opened or fetched.

Failure semantics

  • The projection functions throw nothing of their own; errors come from the reads.
  • Portfolio.currentConditions turns only a stream's PortfolioError CORRUPT into { state: "corrupt", detail }. The registry is read outside that guard, so registry corruption propagates as CORRUPT (HTTP 500), and INVALID for a bad window or asOf propagates too. Journal failures reach the server as 503 JOURNAL_UNAVAILABLE.
  • Portfolio.blockerRecord throws PortfolioError:
    • INVALID: blockerId fails the record-ID pattern, productId fails /^[a-z0-9][a-z0-9-]{0,62}$/, asOf is not a real UTC instant YYYY-MM-DDTHH:mm:ss[.sss]Z in years 2000–2999 (accepted values are normalised to millisecond precision), or staleAfterMs is not an integer from 0 to 366 days in milliseconds;
    • NOT_FOUND: the Product is not registered, or no report of the blocker was captured by asOf;
    • CORRUPT: the Product's stream fails validation.
  • HTTP (src/dashboard-server.ts; every body, including errors, carries protocol):
    • GET /api/attention?period=: 400 for an unknown period, an unsupported query parameter or a repeated one.
    • GET /api/products/:id/blockers/:blockerId: 404 for an undecodable or invalid ID, an unregistered Product or an unreported blocker; 400 for any query parameter. The ID is URL-encoded.
    • POST or DELETE on these routes gives 405 (Allow: GET, HEAD); …/resolve is 404. Nothing is written (server test "no resolve route").
    • With a corrupt usage stream, /api/portfolio returns 500 CORRUPT (server test), because portfolioView reads that stream; productView reads it too. /api/attention still returns 200 with one */stored-facts/usage action.
  • Unknown is not failed or zero.
    • responsible: "unknown" means no source says who can act.
    • details: null means the report was recorded without details, so they are unknown, not empty.
    • Counts describe reported items only; zero actions never shows that nothing is blocked.
    • A retraction is not a resolution.
  • Retries. Nothing is written, so repeating a read is always safe. The same journal, window, asOf and options give the same items, IDs and order. The browser's "Reload facts" reads again and tests nothing.

Trust scope

Established (receipt, which covers the whole guidance and attention increment):

  • Independent review (Astra, xhigh): 0 open must-fix findings and 11 independent tests. Full check: strict typecheck and 350/350 tests; frontend build exit 0.
  • Checks in the Codex in-app browser at 1536×1024 and 390×844 (no horizontal overflow), on a separate journal with synthetic projects:
    • a 40-day-old open blocker appears in the 7-day action view;
    • source resolution removes the action and keeps a two-report history;
    • resolution is labelled as not independently verified, with no unsupported recheck button.
  • Attention and blocker GET and HEAD requests record no event or receipt and run no Git (server test "every new GET and HEAD is read-only").

Not established:

  • No collector checks a blocker prerequisite, and the Factory never verifies a reported resolution. The human interface asks for a clear way to recheck after resolution; what exists is "Reload facts" and recheck: "unsupported".
  • No module in src/ records blocker facts; they appear only after a trusted local import calls Portfolio.recordObservations. Source text is trusted as text only; whether it is true is the source's claim.
  • There is no resolve, dismiss or notification route, no general repair runner, no Outcome assessment and no delivery authority (receipt limits).
  • Two blocker-page sentences are stronger than the domain: "Only the source that reported this blocker can record it resolved, with evidence" (BlockerPage.tsx:221), whereas any imported source's governing report resolves it and reported needs no evidence; and a missing current report is described as withdrawn or retracted (:85, :189), although a correction to another blockerId gives the same state. Correcting those words is a browser change under "Changing it safely".
  • Order is not priority: no source ranks work.
  • The server's stale threshold is fixed at one day.
  • This is a local, loopback-only dashboard: no remote, CI or production claim.

Composition

Depends on: src/dashboard-contract.ts (DASHBOARD_PROTOCOL and DTO types), plus types only from src/portfolio.ts (ConditionsView, BlockerDetails, BlockerRecordView, BlockerView, SourceNoticeView).

Used by:

  • src/dashboard-server.ts:
    • attentionResponse calls projectAttention(portfolio.currentConditions(query), meta, { canCheckRepository: state.allowInspect });
    • blockerResponse calls projectBlockerDetail(portfolio.blockerRecord(productId, blockerId, { asOf, staleAfterMs }), staleAfterMs), with asOf the server clock and staleAfterMs the default.
  • src/dashboard-view.ts:
    • projectProduct calls attentionItems over one ProductView, with usage forced to { state: "ok", sourceNotices: [] }, so project pages never show usage items;
    • projectPortfolio calls attentionCounts(attentionItems(…, NO_CHECKS)) over portfolioView (with its usageSourceNotices) for the overview counts;
    • productDto calls blockerDto for open blockers.
  • Browser, over HTTP only:
    • api.attention and api.blocker;
    • AttentionList on the Actions page and on the project page's "Needs attention", which drops repository-* items (the status band shows them). Its only control besides links is "Check repository" / "Check again", shown when item.check === "repository-availability" and the session's inspect capability is on; it posts the inspect command (useRepositoryCheck), then reloads;
    • BlockerPage;
    • format.ts: attentionText, responsibilityText ("Needs you" only when ownerRequired), targetPath and blockerPath (encoded IDs) and countsText.

Changing it safely

  • Run:

    • focused: node --test test/attention.test.ts test/dashboard-guidance-attention.test.ts test/dashboard-boundaries.test.ts test/dashboard-editor.test.ts;
    • typecheck: npm run typecheck;
    • npm run check before acceptance.

    Browser changes also need the frontend build (npm --prefix apps/dashboard run build) and a real-browser desktop and mobile check.

  • Which tests prove what:

    • attention.test.ts: classification, the owner rule, grouping and correction, periods, repository items, no inferred gates, corrupt-stream isolation, old blocker digests and details bounds, resolution standing;
    • dashboard-guidance-attention.test.ts: routes, typed targets, no resolve route, usage corruption isolated over HTTP, read-only GET and HEAD;
    • dashboard-boundaries.test.ts: the import rule and text-only rendering;
    • dashboard-editor.test.ts: attention words and encoded targets.
  • Contract. Item shapes, kind values, IDs and recheck belong to factory-dashboard/3. The browser refuses any other protocol (apps/dashboard/src/protocol.ts), so a shape change updates the contract and client together. Changing an ID format breaks the stability promise.

  • Receipts. The receipt pins, by SHA-256, a reviewed-source hash list (<local evidence file>) that is not in Git, so which files it covers cannot be checked here. Treat any edit to this module's source, browser files or tests as making that evidence stale. A behaviour change needs a fresh independent review, a new receipt, and updates to dashboard and the build review row.

  • Reviewers check:

    • no new import in src/attention.ts;
    • still no state, dismiss or resolve path;
    • the owner rule is unchanged;
    • missing data never becomes an action;
    • unknown stays unknown;
    • resolution is never shown as verified;
    • DTO copies stay explicit;
    • source text is never treated as HTML, a URL or a command;
    • every GET stays read-only.

Source: docs/agents/attention.md