Skip to content

feat(viewer): render a typed entity page's declared fields and link formats - #189

Draft
PipDscvr wants to merge 4 commits into
atomicstrata:mainfrom
PipDscvr:feat/clp-entity-fields
Draft

feat(viewer): render a typed entity page's declared fields and link formats#189
PipDscvr wants to merge 4 commits into
atomicstrata:mainfrom
PipDscvr:feat/clp-entity-fields

Conversation

@PipDscvr

Copy link
Copy Markdown
Contributor

Slice A of #178, per the A/B/C split agreed on that issue. Refs #178 — it does not close it; B (relations, provenance) and C (artifacts, the two source surfaces) follow.

A typed entity page used to show its body beside a rail describing a contract it was never under: the rail's nine fixed keys are the default profile's vocabulary, so a paper's authors, year, DOI and stage were simply invisible. This makes the page render what its own type declares.

What's in it

§1 — the read model. profilePipeline gains titleField and fields, plus a sibling artifactTypes. No profileSchema block: type, directory, lifecycle and the relation endpoints already ship in profilePipeline, and a second block able to disagree with it about a lifecycle is the shape we've spent three PRs removing. The name is kept, with a docstring stating the block's remit.

§2 — generic rendering. The renderer dispatches on the declared FieldDef.type and never on a field name; test/no-research-branch-in-core.test.ts stays green and unedited. Three value states are reachable and no more — present, absent-and-optional (row omitted), and unresolved artifact reference — because a record violating its declared field contract never becomes a viewer page: collectTypedViewerInputs filters it and it surfaces as a field-violation problem instead. There's a comment at the branch point saying why there is no fourth state.

§6 — declarative link formats. A closed url | doi | arxiv enum, no author-supplied URL templates. Both layers: an unknown format fails validation, and the renderer falls back to plain text for anything unknown or malformed rather than trusting that a validator ran upstream. Origins are out of author control entirely — url links only http:/https:, doi through a fixed https://doi.org/, arxiv through a fixed https://arxiv.org/abs/.

AutoSci declares doi, arxivId and locator formats, taking it to 0.3.0. 0.1.0 and 0.2.0 are both retained as resolvable releases, derived backwards from the current pack — 0.2.0 is 0.3.0 without the formats, 0.1.0 is that without the title declarations — with each published profileDigest pinned in test/profile-template-releases.test.ts, so a later edit that corrupts a derivation fails there rather than quietly mis-describing an installed project.

Digest drift, and what it is not

Adding format to a FieldDef shifts the profile and template digests. It does not reclassify in-flight workflow runs: classifyRun compares workflowDefDigest(def) against run.workflowDigest (src/workflows/status.ts:181-189), and run.profileDigest is pinned at start and shape-checked in run-validate.ts:101 but read by no classification path. Calling this workflow adaptation would send people looking for an effect that isn't there.

Nothing disappears from the rail

Checked rather than assumed, since it was the one open question on §2. appendFrontmatterDl iterates a fixed nine-key RAIL_FIELDS list and skips a key only when the declared block already rendered it. The rail never displayed arbitrary frontmatter, so a key that is both declared and in the nine moves between lists rather than being lost, and an undeclared key outside the nine was never displayed before and still isn't. No regression to place.

One bug found reviewing my own work

bfd45f9 is the same class of defect as the titleField lookup corrected in #187, in the two places this slice introduced it. The schema constrains a field's type to a closed enum but puts no propertyNames on fields or on the entity map — so the declared name is the open half, and every one of these lookups indexed a plain JSON object with one.

  • viewer-entity-fields.js read frontmatter[def.name]. Frontmatter arrives as JSON off the wire, carrying Object.prototype, so a type declaring a field named constructor resolved Object.prototype.constructor on every page of that type and rendered function Object() { [native code] } as the field's value — a value the record does not carry, displayed as though it does. toString and valueOf behave the same.
  • pipeline.ts joined counts onto declarations by indexing summary maps with a declared type name, so a type named constructor produced expected [Function Object] to be +0 — and JSON.stringify then drops the key, so the row reached the client missing a pageCount it is required to carry.

Both are pinned by tests that fail without the fix. FIELD_RENDERERS already took the null-prototype form of this precaution for the declared type; this is the same precaution for the declared name.

Worth your call separately: I hardened the consumers, which is this slice's remit. The underlying gap is that the schema puts no propertyNames constraint on declared names at all. Constraining them upstream would touch profile validation and could reject profiles that currently load, so I didn't fold it in — but it's the root, and it will keep producing this bug class.

Testing

npm run build
npx vitest run test/viewer-entity-fields.test.ts test/viewer-entity-fields-contract.test.ts \
  test/viewer-profile-schema.test.ts test/viewer-pipeline-envelope.test.ts \
  test/viewer-rail.test.ts test/profile-template-releases.test.ts

To see it: llmwiki view on an AutoSci project and open a paper — authors, year, stage and a working DOI link where there was a slug and a generic rail. The same renderer against the Newsroom fixture, with no domain-specific conditional.

Full gate: tsc --noEmit, npm run build, 4989 passed / 0 failed, fallow 0 above threshold with no dead code or duplication.

One environment note: the suite needs LLMWIKI_PROVIDER=anthropic with no ANTHROPIC_API_KEY on a machine where Claude Code is logged in — claude-agent requires no credential env var (provider-guard.ts:45) and authenticates from ~/.claude, so tests asserting a missing-credential error find working auth and fail. Same note as on #179.

Next

B — §3 relation drilldown and §4 provenance, byte-free, reusing the relation instances already captured at snapshot creation, with the per-page list capped and the true total reported separately following /api/reviews. §4 links to the source entry, not the cited lines.

C — §5 artifacts and §7 the two source surfaces, written last so they sit at the top of the log: bytes on loopback only, non-loopback gets metadata and health, and the pinned hash re-verified at request time on every artifact response.

Slice A Task 1 of atomicstrata#178. `profilePipeline` now carries `titleField`, the
declared `fields`, and the profile's `artifactTypes` — the read model §1
asks for, extending the block atomicstrata#177 introduced rather than adding a parallel
`profileSchema`. Four of §1's six items already shipped there, so a second
block would have put the lifecycle on the wire twice and given a reader two
sources that can disagree about it.

The drop boundary is closed by the type system, not by a test. The facets
the wire refuses — `default`, which can carry arbitrary author-supplied
data, and `min`/`max`, which nothing renders — are named as
`DroppedFieldFacet`, and everything else is an EXHAUSTIVE
`Record<ProjectedFieldFacet, true>`. A facet added to `FieldDef` fails to
compile there until someone decides which side it belongs on; an array
would have silently accepted a subset. Verified by probe: adding a field to
`FieldDef` produces TS2741 at that record.

Fields ride as an ordered array rather than a map, because object key order
is not a contract a client should trust and the author's declaration order
is the only order this projection does not invent.

`maxBytes` is deliberately not projected — it is a ceiling enforced on the
handle, not something a reader is shown. It belongs on the wire the day a
surface renders it.

The envelope join is tested separately from the projection: an artifact
type has no count to join onto it, so it could be dropped there without any
projection test noticing, and the shipped newsroom fixture declares no
artifact types to catch it.
Slice A of atomicstrata#178: §1 the read model, §2 generic rendering, §6 the field-format
vocabulary, and the AutoSci configuration those need.

**The read model extends `profilePipeline`** rather than adding a parallel
`profileSchema`, per the issue thread: four of §1's six items already ship
there, and a second block would put the lifecycle on the wire twice. It
gains `titleField`, the declared `fields`, and a sibling `artifactTypes`.

The projection's drop boundary is closed by the type system. The refused
facets — `default`, which can carry arbitrary author-supplied data, and
`min`/`max`, which nothing renders — are named as `DroppedFieldFacet`, and
the rest is an exhaustive `Record<ProjectedFieldFacet, true>`. Adding
`format` to `FieldDef` in this same change failed to compile there until it
was explicitly projected, which is the guard doing its job rather than a
hypothetical.

**The renderer dispatches on declared type, never on a field name.** A
renderer that knew what `doi` meant would work for one profile and quietly
do nothing for the next, and `src/` may not name a domain vocabulary at all.
`no-research-branch-in-core` stays green and unedited.

Three value states are reachable and no more: present, absent-and-optional,
and unresolved artifact. A record violating its field contract never becomes
a viewer page, so there is no fourth branch to write.

**The rail's own list was the default profile's vocabulary**, not raw
frontmatter as assumed — nine fixed keys describing a contract a typed page
was never under. So nothing disappears: the declared fields lead, and that
list still runs, minus any key the profile declares and minus the field the
heading already shows.

**`format` is a closed enum with fixed origins.** A URL template would be
executable profile behaviour on a read surface. The renderer guards
independently of load validation — `/api/pages` is a wire boundary — and
returns text for anything it is not certain about, so a `javascript:` value
renders as text rather than as a link built anyway.

AutoSci declares `doi`/`arxiv`/`url` formats and advances to 0.3.0, with
0.1.0 and 0.2.0 both retained and their published digests pinned.

Review findings addressed: the `format` rule now covers all three `FieldDef`
carriers (relation attributes and artifact metadata resolve to the same
`$defs/fieldDef`, as `assertArtifactTypesScoped` already accounts for);
`artifactRef[]` no longer renders as plain scalars, which would have shown
unverified refs indistinguishably from checked ones; the label rule is
scoped to out-specify `.support-rail dt`, which was silently winning; and
the page route awaits the envelope so both render passes paint the same
rail. One reported finding — an `empty-page` lint regression — was refuted:
the linter reads `meta.title` directly and never sees the collector's
resolved title.
Self-audit findings that belong to Slice A. The two title-related
regressions the same audit found are fixed in the parent PR, since that is
the PR that introduces them.

**Nothing bound the projection to the renderer.** Both halves were pinned
against a hand-written envelope in the middle, so they agreed by care rather
than by construction — a fixture drifting in lockstep with its consumer
would have passed both suites with the feature dead in the browser. A new
contract test runs the real projection and feeds its output to the real
renderer; breaking the seam now fails it.

**The DOI grammar rejected valid DOIs for no security gain.** It forbade
`/`, `?` and `#` in a suffix to stop path steering — but the origin is a
literal prefix, so no suffix can escape it. It just dropped real identifiers
like `10.5061/dryad.abc/1` to plain text. The grammar now identifies and a
new origin check contains, which is the property that actually mattered.
Returning the resolved `href` also means the validated string is the
navigated one.

**A `titleField` naming a fixed-rail key resurrected in the other list.**
Hiding it from the declared block stopped it suppressing the fixed row, so
it reappeared beside the heading it duplicates. Suppression is now computed
from the unfiltered declarations.

**The cascade was untestable.** The JSDOM suites assert DOM structure and
never load a stylesheet, so `.entity-field-label` losing to `.support-rail
dt` was invisible to every test — review caught it, nothing else could. A
specificity guard now covers that failure mode, verified by reintroducing
the exact bug that shipped and watching it fail. It checks specificity and
source order only, which is what that bug was; it is not a browser, and its
docblock says so.

Also: the new link colour joins the contrast pins, where it clears 4.5:1 in
both themes.
Same class of bug as the `titleField` lookup corrected in atomicstrata#187, in the two
places slice A introduced it. The profile schema constrains a field's `type`
to a closed enum but puts no `propertyNames` on `fields` or on the entity map,
so a declared NAME is the open half — and every lookup below indexes a plain
JSON object with one.

`viewer-entity-fields.js` read `frontmatter[def.name]`. Frontmatter arrives as
JSON off the wire, carrying `Object.prototype`, so a type declaring a field
named `constructor` resolved `Object.prototype.constructor` on EVERY page of
that type: `isPresent` saw a function, and the row rendered
`function Object() { [native code] }` as the field's value — a value the record
does not carry, displayed as though it does, which is the one thing this
surface exists not to do. `toString` and `valueOf` behave the same.
`FIELD_RENDERERS` already took the null-prototype form of this precaution for
the declared type; this is the same precaution for the declared name.

`pipeline.ts` joined counts onto declarations by indexing summary maps with a
declared type name. A type named `constructor` resolved a function, which then
read as a truthy `stateCounts` and as a `count`/`pageCount` that
`JSON.stringify` drops — so the row reached the client missing a key it is
required to carry. The lifecycle `enum` lookup had the same shape; that one
resolved to `undefined` either way, so it is defence in depth rather than an
observable fix, and shares the helper rather than being left as the one bare
index in the file.

Both are pinned by tests that fail without the fix — the renderer case renders
"native code" into the page, and the envelope case asserts
`expected [Function Object] to be +0`. The guard matches the `Object.hasOwn`
convention already used in `validate.ts` and `validate-workflow-actions.ts`.
@PipDscvr
PipDscvr requested a review from ethanj August 21, 2026 18:22
@ethanj
ethanj marked this pull request as draft August 21, 2026 22:18
@ethanj

ethanj commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Reviewing this as a draft, because early signal on 2000 lines is worth more than late signal. One thing to fix; everything else here is confirmation.

You honoured every ruling from #178, including the one where I overruled you: titleField resolves in the collector and the viewer projects only the declaration. maxBytes deliberately not projected is the one I was most worried about, since my §4 wording in the issue was loose enough to pull byte-serving into Slice A. You resolved that ambiguity the right way.

One thing to fix: reserved characters truncate the DOI before it reaches the resolver

DOI_PATTERN accepts \S+ on purpose, and the docstring is right that an over-tight grammar would degrade valid identifiers. But new URL(value, base) reads two of those permitted characters as component delimiters rather than as identifier text, so the resolver never receives the whole DOI:

10.1002/(SICI)1099-0518(199611)34:15<3129::AID-POLA9>3.0.CO;2-#
  href       https://doi.org/10.1002/(SICI)...3.0.CO;2-#
  pathname   /10.1002/(SICI)...3.0.CO;2-        <-- the "#" became an empty fragment
  server sees the truncated form, and doi.org 404s

10.1234/abc?x=1
  pathname   /10.1234/abc          search  ?x=1
  same truncation, via "?" instead

< and > are fine: the URL parser percent-encodes them and they survive in the path. It is specifically the two characters that open a new URL component. Crossref's own guidance on suffixes with special characters requires # to be sent as %23, and the first case above is their published example.

This is reachable rather than theoretical, since the Crossref connector preserves the DOI body as written.

The fix is to encode the reserved characters in the suffix while leaving the DOI path separators alone. Worth pinning Crossref's published example as a regression test, and ? alongside it, since the two fail identically and a fix aimed only at # would leave the other one silently truncating.

Nothing else in the guard moves. The origin comparison is doing the real containment, exactly as your docstring argues, and it holds under everything I threw at it: javascript: with an embedded newline, an absolute https://evil.test smuggled into a DOI, protocol-relative arxiv, and formats named __proto__, constructor and toString all return null.

Two things worth naming because they were not asked for

The regression check is stronger than the one I specified. I asked that nothing the rail shows today disappears. What you wrote drives the real projection into the real renderer, and opens with renders a block at all, which a shape mismatch would silently prevent. That is a vacuity guard on the rest of the suite, and it is the difference between a test that catches a projection and renderer drifting apart and one that passes while both do.

You swept own<T>() across every profile-supplied name position and cited validateTitleField for the reasoning. That is the same class of defect applied to entity types, relation types and lifecycle fields, which the profile schema leaves unconstrained. Finding the sibling surface of a bug without being asked is the part I would otherwise have had to request.

Checked rather than read

The retained-release digests survived the rename to prior-releases.ts. I recomputed the new 0.2.0 pin from main independently and it matches, and 0.1.0 still carries the value verified during #180, so generalising the helper to N releases did not quietly loosen the control.

Both guards kill their mutants: passing everything through the link formatter turns 14 tests red, and letting format validation accept any field type turns 4 red.

It merges clean onto current main despite being three commits behind, with type-check clean and 116 tests green across the new and touched suites. Worth a rebase before you take it out of draft, since the CI green here is against 171d8895 rather than what it will land on.

Good work. Fix the DOI encoding and I think this is done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants