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;BlockerDtoandBlockerDetailsDto,:54-80). - Browser:
apps/dashboard/src/attention-list.tsx,apps/dashboard/src/views/ActionsPage.tsx,apps/dashboard/src/views/BlockerPage.tsx; words inapps/dashboard/src/format.ts. - Tests:
test/attention.test.ts(8),test/dashboard-guidance-attention.test.ts(3 of its 6 are attention routes); alsotest/dashboard-boundaries.test.ts(imports) andtest/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,externalorunknown), 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.currentConditionsreads each Product stream and the usage stream separately; this turns each unreadable one into onefacts-unreadableaction, 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
reportedorreported-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): normallyPortfolio.currentConditions(query). Each product isProductConditions | CorruptProduct; a fullProductViewalso fits.usageis{ 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 };ownerRequiredcounts actions only.
Blocker detail
projectBlockerDetail(record: BlockerRecordView, staleAfterMs: number): BlockerDetailDto(:189), normally overPortfolio.blockerRecord(productId, blockerId, { asOf, staleAfterMs }). Result:{ protocol, asOf, staleAfterMs, productId, displayName, blockerId, streamVersion, current, history, totalRevisions, recheck: "unsupported" }.currentisBlockerDto & { state: "open" | "resolved", observedAt, resolution, responsible, ownerRequired }, ornullwhen no current report carries thisblockerId(every record retracted, or corrected to another ID). Eachhistoryentry (reverse journal order, capped at 50) is{ state: "open" | "resolved" | "retracted", report, source, observedAt, capturedAt }, withreport: nullfor 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
| Name | Value | Where |
|---|---|---|
MAX_WINDOWS | 5 windows per grouped source item | private, :31 |
MAX_DETAIL_LENGTH | 280; 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.maxTextLength | 280: prerequisite, nextAction, exhaustion and every list entry except affected are 1–280 characters of trimmed, printable text | portfolio |
PORTFOLIO_LIMITS.maxListItems | 20 each for affected (record IDs, same pattern as above) and remediesTried | portfolio |
PORTFOLIO_LIMITS.maxBlockerDetailItems | 10 each for steps, remedyEvidence, resolutionEvidence | portfolio |
PORTFOLIO_LIMITS.maxBlockerRevisions | 50 history entries; totalRevisions gives the full count | portfolio |
PORTFOLIO_LIMITS.defaultStaleAfterMs | 86_400_000 (one day); the server always passes this | portfolio |
PORTFOLIO_LIMITS.maxObservationBytes | 8_192 canonical JSON bytes per observation, so the per-field maxima above do not all fit at once | portfolio |
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.)
kind | Category | id | target | Extra fields |
|---|---|---|---|---|
facts-unreadable | action | <productId>/stored-facts/unreadable or */stored-facts/usage; productName: null | project, or { kind: "portfolio" } | detail (bounded) |
blocker | action; 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-unavailable | action | <productId>/repository/unavailable | project | availability, source, capturedAt, stale |
source-problem (unavailable, unauthenticated, corrupt) | action | <productId or *>/source/<basis>/<status> | project or portfolio | basis, status, windows, records, source, capturedAt |
repository-unchecked | notice | <productId>/repository/unchecked | project | none |
repository-stale | notice | <productId>/repository/stale | project | source, capturedAt (no stale flag; the kind says so) |
source-partial | notice | <productId or *>/source/<basis>/partial | project or portfolio | as 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
- Pure and stateless. It imports only
./dashboard-contract.tsand 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. - 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").
- Owner rule.
ownerRequiredis true iffdetails.responsible === "owner" && details.exhaustion !== null(:179). The detail view also requiresstate === "open". Missing details or a nullresponsibleread as"unknown"(test "open blockers are actions with honest responsibility"). - 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 itscapturedAt; a retracted record drops out), thenlatestBykeeps one governing report perblockerIdacross the surviving records: the greatestcapturedAtwins, later journal order breaking ties. The item leaves when the governing report saysresolved. 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'sblockerIdmoves that record's contribution to the new ID; the old ID stays current if another record still reports it. - 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.asOfis a capture cutoff: a report captured after it is not in the read. - Source grouping. Notices group per scope by
basis/status.recordscounts all of them, andwindowskeeps the first five in Portfolio's order (window start, then end, then collector and record).sourceandcapturedAtcome from the most recently captured declaration.partialis a notice;unavailable,unauthenticatedandcorruptare actions. - Repository. An
unknownreading isrepository-unchecked. Any availability other thanavailableisrepository-unavailable, whatever its age.availablebut stale isrepository-stale;availableand fresh makes no item (test "repository items"). - Isolation. A
CorruptProduct, orusage.state === "corrupt", is onefacts-unreadableaction, 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"). - 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), thenid. It implies no business priority. - Resolution is a source's report.
resolutionisnullwhile open. When resolved it isreported-with-evidenceifresolutionEvidenceis non-empty, elsereported.currentisnullwhen no current report carries the ID: history keeps a retraction asstate: "retracted"withreport: null, while a record corrected to anotherblockerIdadds no entry under the old ID, whose earlier reports remain. Neither is a resolution.historyis in reverse journal order (what the contract calls newest first), capped at 50: not necessarily descendingcapturedAt, and the governing report need not be its first entry.recheckis always"unsupported"(test "blocker detail: resolution is a source's report…"). - Explicit copies.
blockerDto,blockerReportDtoand the privatedetailsDtoselect named top-level fields only. Nestedsource, arrays and windows are reused by reference from Portfolio's parsed, frozen views; this is not a recursive filter. - 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.currentConditionsturns only a stream'sPortfolioErrorCORRUPTinto{ state: "corrupt", detail }. The registry is read outside that guard, so registry corruption propagates asCORRUPT(HTTP 500), andINVALIDfor a badwindoworasOfpropagates too. Journal failures reach the server as 503JOURNAL_UNAVAILABLE.Portfolio.blockerRecordthrowsPortfolioError:INVALID:blockerIdfails the record-ID pattern,productIdfails/^[a-z0-9][a-z0-9-]{0,62}$/,asOfis not a real UTC instantYYYY-MM-DDTHH:mm:ss[.sss]Zin years 2000–2999 (accepted values are normalised to millisecond precision), orstaleAfterMsis 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 byasOf;CORRUPT: the Product's stream fails validation.
- HTTP (
src/dashboard-server.ts; every body, including errors, carriesprotocol):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);…/resolveis 404. Nothing is written (server test "no resolve route"). - With a corrupt usage stream,
/api/portfolioreturns 500CORRUPT(server test), becauseportfolioViewreads that stream;productViewreads it too./api/attentionstill returns 200 with one*/stored-facts/usageaction.
- Unknown is not failed or zero.
responsible: "unknown"means no source says who can act.details: nullmeans 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,
asOfand 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 callsPortfolio.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 andreportedneeds no evidence; and a missing current report is described as withdrawn or retracted (:85,:189), although a correction to anotherblockerIdgives 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:attentionResponsecallsprojectAttention(portfolio.currentConditions(query), meta, { canCheckRepository: state.allowInspect });blockerResponsecallsprojectBlockerDetail(portfolio.blockerRecord(productId, blockerId, { asOf, staleAfterMs }), staleAfterMs), withasOfthe server clock andstaleAfterMsthe default.
src/dashboard-view.ts:projectProductcallsattentionItemsover oneProductView, with usage forced to{ state: "ok", sourceNotices: [] }, so project pages never show usage items;projectPortfoliocallsattentionCounts(attentionItems(…, NO_CHECKS))overportfolioView(with itsusageSourceNotices) for the overview counts;productDtocallsblockerDtofor open blockers.
- Browser, over HTTP only:
api.attentionandapi.blocker;AttentionListon the Actions page and on the project page's "Needs attention", which dropsrepository-*items (the status band shows them). Its only control besides links is "Check repository" / "Check again", shown whenitem.check === "repository-availability"and the session'sinspectcapability is on; it posts the inspect command (useRepositoryCheck), then reloads;BlockerPage;format.ts:attentionText,responsibilityText("Needs you" only whenownerRequired),targetPathandblockerPath(encoded IDs) andcountsText.
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 checkbefore acceptance.
Browser changes also need the frontend build (
npm --prefix apps/dashboard run build) and a real-browser desktop and mobile check.- focused:
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,
kindvalues, IDs andrecheckbelong tofactory-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;
unknownstays 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.
- no new import in
