Skip to content

feat(spec,cli): let an app contribute its own first-run credentials to the boot banner (devHint / devLogins[]) - #19139

Draft
os-bill wants to merge 6 commits into
mainfrom
claude/issue-17556-app-contributed-first-run-credentials
Draft

os-bill wants to merge 6 commits into
mainfrom
claude/issue-17556-app-contributed-first-run-credentials

Conversation

@os-bill

@os-bill os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17556

Clause-②: yes (widening)

Suggestion 1 of #17081, filed onto its own card so PR #17555's Closes would not take it
down with the parent. #17555 took the platform's half — the 🔑 Dev admin line now says
what that account will and will not see. This is the half packages/cli structurally
could not write: the platform can describe the account it seeds, and it cannot know that an
application's Hiring group needs a position or which of five personas a demo should open
with. The application knows, and now has a channel to say so.

What an operator sees

Nothing changes unless the application declares something, and nothing changes outside a
development boot. With both keys declared:

  🔑  Dev admin: admin@objectos.ai / admin123
      seeded on empty DB · dev only — do not use in production
      platform admin — Setup, Studio and every record, but NO app-declared capability, so
      an app that gates navigation on requiredPermissions may show it an empty menu; grant
      it a permission set under Setup → Users, or sign in as an account your app seeds

  👥  App logins: 2 declared by this app
      Hiring admin — admin@quillstone.example / demo1234
      Job seeker — candidate01@mail.example / demo1234
      declared in this app's `devLogins` · dev only — the platform seeded none of them

  💡  App hint:   run `pnpm seed:demo` first, then sign in as the Hiring admin

The accept-set delta, stated so a contract review can check it

The top-level stack door has been strict since #8687, so both spellings were an
unrecognized_keys refusal before this change. What is writable after that was not
writable before, and nothing else:

surface before after
ObjectStackDefinitionSchema.devHint refused (unrecognized_keys, path []) string, optional
ObjectStackDefinitionSchema.devLogins refused (unrecognized_keys, path []) array of DevLogin, optional
DevLogin.email did not exist string, required, email-validated
DevLogin.password did not exist string, optional
DevLogin.label did not exist string, optional
any other key inside a devLogins entry did not exist refused — the entry is strictObject from birth
any other new top-level key refused still refused (pinned as the dark control)

Nothing is renamed, nothing is retired, no value that parsed before is refused now, and
the delta is recorded in two tracked artifacts a review can read rather than take on
trust: packages/spec/authorable-surface/system.json gains exactly
system/DevLogin:email, :label, :password, and
packages/spec/api-surface-declarations/root.txt gains the two keys on
ObjectStackDefinitionSchema.

New exports, from @objectstack/spec/system: DevLoginSchema, DevLogin,
DevLoginParsed.

Where the declaration belongs, measured

The card said its landing point was measured as packages/spec yet named only two
packages/cli files. Both readings are right and they are two different halves:

  • Declaration: packages/spec. The banner prints once per boot, for the whole booted
    stack, so the key is stack-level — not per-App (N apps, one banner) and not per-package
    (the reader looks at the top level). devPlugins is the standing precedent for a
    development-only, CLI-consumed, stack-level key.
  • Consumption: packages/cli. serve.ts already holds the parsed definition in the
    same scope as the banner call (it reads config.flows two lines away, and
    config.devPlugins in the same function), so the values reach printServerReady with
    no new plumbing.

DevLoginSchema itself lives in packages/spec/src/system/dev-login.zod.ts, mounted into
stack.zod.ts — the same shape StackServerConfigSchema already has. Two reasons, one
architectural and one mechanical: element schemas live in their domain file rather than in
the 4000-line envelope, and declaring it inside stack.zod.ts displaced
ObjectStackDefinitionSchema off the five-export line of
skills/objectstack-platform/references/_index.md, dragging a PD-14 governed surface into
this diff for a generated one-line churn. Measured both ways: with the schema in
stack.zod.ts, check:skill-refs went red and regenerating produced that churn; with it
in system/, check:skill-refs is green and the file is untouched. This PR touches no
governed surface
(docs/adr, .claude, skills, AGENTS.md, CLAUDE.md — the
docs/ path here is docs/audits/, a regenerated ratchet count).

Suggestion 1 was not partly landed by #17555 — read first-hand

PR #17555's file list is three files: its changeset,
packages/cli/src/utils/format.ts, and one new test. No packages/spec file. Its body
says so in its own words: the card's suggestion 1 "is the right long-term shape and is
not taken here: it needs a new key on a published schema in packages/spec, another
seat's lane". Independently re-derived on origin/main at b7eaf6a61: git grep -nI for
devLogins|devHint printed no lines at all; the lit control (seededAdmin, the same
subject) printed 3 files; the dark control (an impossible identifier) printed none. A second
probe over packages/spec/src for any pre-existing dev-credential declaration
(devAdmin|devLogin|devCredential|firstRun|devPersona|seedAdmin) printed exactly one line,
an unrelated "u_devadmin" group key inside an approval-service docblock; lit control
(dev-prefixed keys) printed 4 lines, dark control none.

Three decisions the card left to the taker

The card listed three, marked "⛔ not prescribed here". Taken, with reasons:

  1. Where the key lives, and one hint or a list — both, and stack-level. They answer
    different needs and each has its own composition rule: devLogins is 'concat', so
    composing two applications keeps both publishers' personas (dropping one would hide an
    audience the composed artifact still serves — the packages argument, one layer over);
    devHint is 'single', so two stacks with contradicting first-run instructions is a
    composition error naming the key, never a silent last-wins. Both are artifact ENVELOPE
    keys: refused inside packages[].manifest, exactly as devPlugins is, because the
    banner's only reader looks at the top level and a folded copy would be parsed, stored
    and never printed.

  2. Is printing application-supplied credentials safe — yes, with two constructions.
    The content is not an escalation: anyone who can write devLogins can already write
    onEnable, a function the dev process executes, so these values carry the trust level
    of the code being booted. Two things genuinely are new, and both are closed by
    construction rather than by assurance:

    • Author-controlled text now reaches a TTY. A terminal obeys control bytes, so an
      escape sequence in a hint could erase the rows above it or repaint a forged
      🔑 Dev admin row — the platform's own banner lying on the application's behalf.
      Every C0/C1 byte is replaced with U+FFFD before printing, so an entry occupies
      exactly the rows it was given. Visible characters pass through unchanged: this is
      neutralisation, not redaction, so an author who wrote one can see that they did.
    • A production boot must not print them. The block is gated on isDev
      (--dev or NODE_ENV=development — the same condition family that lets
      maybeSeedDevAdmin fire at all). Any other boot renders byte-identically to one
      declaring nothing, so ADR-0115's attention-budget paragraph is untouched on the path
      it was written about.

    The residual, stated rather than hidden: these values are committed to the
    application's repository already. The schema says so where an author reads it —
    development fixture, never a real secret, never a value that also opens a deployed
    environment.

  3. What the banner does when an application declares nothing — exactly what it does
    today.
    Byte-identical, asserted for absent, empty-array and blank-string
    declarations, with a positive control proving the same comparison does move when
    something is declared.

One decision the card did not list, taken the same way: the block is additive, beneath
the seeded-admin lines and never instead of them. An application-controlled key able to
suppress a platform disclosure would let an app hide a live credential the operator was
just handed. And declaring is not seeding: an entry creates no account, which is why
the banner says the application declared it — an entry naming an unseeded account then
reads as the app's claim rather than as a broken platform.

Red / green / dark

New pins: packages/spec/src/stack-dev-logins.test.ts (13) and
packages/cli/src/utils/format.server-ready-app-logins.test.ts (13). Rejections assert the
Zod issue code and path, never a bare throw.

  • Green — both keys parse, carry their values through safeParse and through
    defineStack (the An unknown top-level stack key is named but still not rejected — and the diagnostic is not a warning, so objectstack validate --strict cannot catch it either (17.0.0 GA) #8687 defect was a key that parsed green and was then stripped);
    devLogins concatenates across a compose; an identical devHint passes through.
  • Red — an undeclared key inside an entry raises unrecognized_keys at path
    ['devLogins', '0']; username and note are routed to email and label by the
    curated aliases; a missing address raises invalid_type at devLogins.0.email; a
    non-address is refused at the same path; a non-string devHint raises invalid_type;
    two differing hints throw naming top-level key 'devHint'.
  • DarkdevLoginz, devHints and devCredentials still raise unrecognized_keys
    at path [], so the green above reads as "two keys were declared" and not as "the strict
    close regressed". On the banner side, control-byte-free text passes through with no
    U+FFFD anywhere, so "the bytes were replaced" is a measurement.

Ablations (scripts/ablation-replace.mjs, on-disk proof and restore verified by blob
hash plus an empty git diff HEAD each time):

mutation anchor result
drop .email() from DevLogin.email 1 to 0, blob d6b5d27f8e38 to b0c0abd3c80c exactly 1 of 13 red — the address pin. The other 12 stay green, so the ablation is attributable rather than blunt
delete the devLogins key from the stack shape 1 to 0, blob 11f3c284217a to b2611a37d33f 8 of 13 red — every accept, refuse and compose pin. The accept leg is not vacuous

Cross-package reverse verificationpackages/cli types against a schema built in
packages/spec, so the green had to be shown to be a reading of the rebuilt .d.ts and
not a cache. Replacing entry.email with entry.nope in format.ts (anchor 1 to 0, blob
8143c65513c9 to 1375d1655de6) turned pnpm --filter @objectstack/cli typecheck red
with TS2339: Property 'nope' does not exist on type '{ email: string; password?: string | undefined; label?: string | undefined; }' — the message quotes the exact shape declared in
system/dev-login.zod.ts. Restored, blob back to 8143c65513c9, git diff HEAD empty,
typecheck green again.

Verification

Measured at 53f78a5796, this branch.

  • pnpm --filter @objectstack/spec test495 files / 14534 tests pass; typecheck green.
  • pnpm --filter @objectstack/cli exec vitest run --project unit218 files / 3077 tests pass; typecheck green. The integration tier is declared to CI (pnpm test runs both); the diff adds no spawn entry point and changes no boot behaviour beyond two option values.
  • pnpm --filter @objectstack/spec check:generated16 of 16 artifacts up to date.
  • pnpm lint (eslint . --no-inline-config, the whole repository, not a narrowed run) — green in 78s.
  • node scripts/pm/dispatch-gates.mjs --ran131 derived families accounted for: 129 run green, 2 NOT MEASURED. The two are check:dual-build-cjs-loads and check:type-check-debt, which both exited 3 — PREREQUISITE NOT MET: each needs every workspace package built, which is a repository-wide build CI performs on a fresh checkout. Neither is a failing measurement. check:type-check-coverage, the per-package half of the same pair, is green.

Two gates asked for a hand edit and got one rather than a regeneration, because neither has
a generator: packages/spec/llms.txt (the system domain is 34 schemas, the tree 202) and
content/docs/getting-started/quick-reference.mdx (System Protocol is 16 of 34; the table
is a curated subset, so N is left alone — adding a row is a decision, not a fix).

Acceptance notes

Found while measuring, deliberately not fixed here:

  • packages/spec/src/stack.zod.ts, the STACK_DEFINITION_COLLECTIONS_SHAPE docblock says
    the envelope keys "stay on the schema below and are deliberately NOT in here:
    manifest, packages, api, server, i18n, runtimeModule and onEnable". Five
    of those seven are in fact declared inside that shape (api, server, i18n,
    onEnable, runtimeModule); only manifest and packages are outside it. Comment
    only — no runtime or accept-set effect. The paragraph this PR does edit is the separate
    one about plugins / devPlugins, which was accurate and is kept accurate; the stale
    sentence is left untouched rather than corrected as a rider.
  • packages/cli/src/commands/serve.ts, in resolveStorageCapabilityArg's header: the
    reason config.storage is not read says "the schema is not .strict(), so
    defineStack ... strips it before serve could ever see it". An unknown top-level stack key is named but still not rejected — and the diagnostic is not a warning, so objectstack validate --strict cannot catch it either (17.0.0 GA) #8687 closed that door —
    the top level is strictObject and an undeclared storage is now refused at parse, not
    stripped. The conclusion still holds; the stated mechanism is one release stale. Comment
    only.

Neither is a reproducible defect, a contract violation, or a trap that makes an author
write metadata the runtime rejects or drops, so neither is filed as a card.

What this PR does not do


Generated by Claude Code

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 11 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/data.txt, packages/spec/api-surface-declarations/root.txt, packages/spec/api-surface-declarations/system.txt, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ee5812a5e3931b64037cb46e255649a9da0e7b74.

7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 11 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/data.txt, packages/spec/api-surface-declarations/root.txt, packages/spec/api-surface-declarations/system.txt, …) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ee5812a5e3931b64037cb46e255649a9da0e7b74packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3d6f451e9e8869d73996ffe2e49f6ffd79d83022 — the merge of head 53f78a5796d40518d7864da14dee122d52d0bfc8 into base ee5812a5e3931b64037cb46e255649a9da0e7b74, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3d6f451e9e8869d73996ffe2e49f6ffd79d83022 && git checkout 3d6f451e9e8869d73996ffe2e49f6ffd79d83022
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee5812a5e3931b64037cb46e255649a9da0e7b74 53f78a5796d40518d7864da14dee122d52d0bfc8 && git checkout -B drift-repro ee5812a5e3931b64037cb46e255649a9da0e7b74 && git merge --no-ff 53f78a5796d40518d7864da14dee122d52d0bfc8

node scripts/docs-audit/affected-docs.mjs --json ee5812a5e3931b64037cb46e255649a9da0e7b74

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ee5812a5e3931b64037cb46e255649a9da0e7b74 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Seat note — carrier completed, two readings a reviewer will need

domain:spec seat 2, on head 53f78a5796 (2026-09-18T22:56Z). ⛔ This PR is not enqueued and must not be: Clause-②: yes, so it owes an at-tier contract review before it goes near the queue.

1. The contract-review carrier is now whole. needs:contract-review sat on card #17556 and not on this PR — a dual carrier written half way, which is the seat's stroke and not the dev's. Added by a targeted POST /issues/19139/labels (⛔ not a whole-set PUT, which replaces the union); the five labels already there were read back first and are unchanged: documentation, size/xl, tests, tooling, protocol:system. check-clause2-carriers --pair 19139 now exits 0 — "both carriers agree" — where it exited 4 before.

2. api-surface-declarations/ui.txt is +165/−165 and none of it is a surface change — confirmed by the seat, at the grain the claim is made at.

⚠️ My first instrument read this wrong and is worth recording. A line-grain multiset diff of the patch reports 8 net different lines, which reads as "something changed". It is the wrong instrument: the claim is about enum members, and a line whose members were reordered is a different line carrying the same set. Re-measured at member grain (2026-09-18T22:56Z), on the same patch:

grain reading
line 8 net different lines
line, with each line's quoted members sorted first 0
every quoted literal on every changed line 440 added, 440 removed, 0 net difference

So no member is added and none removed anywhere in ui.txt; the four line pairs that differ are FieldType, the view type union, and an HTTP method union in a different order. The real surface additions are where they should be — root.txt (+14: devLogins, devHint and the entry shape) and system.txt (+21: DevLoginSchema, DevLogin, DevLoginParsed, and the export counts 808/847). data.txt's +1/−1 is pure reordering at line grain already.

3. The body's coverage line understates itself, and the seat has NOT re-measured it. The Verification section records 2 of 131 derived gate families as NOT MEASURED — check:dual-build-cjs-loads and check:type-check-debt, both exit 3 PREREQUISITE NOT MET. The dev reports both were cleared minutes after the PR opened by a full turbo run build plus a spec rebuild, and that the true reading is 131 derived / 131 run / 0 NOT MEASURED.

⚠️ That correction is the dev's measurement, not the seat's: the worktree it was taken in no longer exists on this box, and re-deriving it means a fresh checkout and a full 72-package build, which is not a cost this note is worth. So it is recorded here rather than patched into the body — the body stays the record of what was true when it was written, and this comment carries the later reading, explicitly attributed. The contract reviewer should treat "2 NOT MEASURED" as unresolved until re-run, ⛔ not as either reading confirmed.


Generated by Claude Code

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

Projects

None yet

2 participants