Skip to content

feat(spec): curated activityMilestones, publicSharing, userActions and inlineColumns form rows (#19332, flight G2b) - #20485

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19332-g2b-remaining-g2-rows
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19332-g2b-remaining-g2-rows

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Part of #19332
Flight G2b of ruling 5861442317.

Clause-②: no

Status: draft, no open gap

The first round stopped at one red test outside the claim's surface, packages/lint/src/validate-predicate-path-refs.test.ts, the lint census of every predicate the shipped metadata forms carry. The claim both admitted mechanically moved population pins and forbade packages/lint/**. The seat answered A and amended claim 5873857698 in place (its "Amended 2026-09-28T17:27Z" line): that one file joined the surface for its census pin only. The patch round landed it in 16037890 (Pins moved below), and the file reads 54 of 54.

Under the claim's second amendment ("Amended 2026-09-28T17:51Z"), 2bcad436 corrects one G2a text: the indexes.fields sub-row's help text (and its code comment and four catalogue leaves) now reads "Saving does not check them; publishing and os validate refuse a name that is not a field of this object. A field that is not a stored column (a formula, say) makes the SQL driver skip the whole index, with a warning in the server log." Each clause was measured on this tree after 4b2d9041 (#20479): ObjectSchema.safeParse accepts a misspelt column; os validate's rules and the runtime publish gate (runRuntimeAuthoringRules, type object) both return object-field-ref-unknown at error on it; and an in-memory SQLite SqlDriver sync skips an index on a formula field (and one on a misspelt name) with [sql-driver] skipping declared index … column(s) not materialized at warn, while the index on a stored column is created.

What

Four live keys had no form row, so an author could reach them only through the Source tab. Each is now a row with hand-written sub-rows, as the ruling says: 「G2 (…) — hand-written curated sub-rows, plus … one nested subset row for inlineColumns」. The four-locale catalogue rows are in this PR.

key form, section row, sub-rows (face) the row each copies
object.activityMilestones object.form.ts, Advanced, after validations type: 'repeater': field (widget: 'text', required), value, summary (text, required), type (text) the fieldGroups repeater face (declared, labelled sub-rows); the text sub-rows copy the plain text rows in Basics
object.publicSharing object.form.ts, Advanced, after requiredPermissions type: 'composite': enabled (switch), allowedAudiences and allowedPermissions (widget: 'multiselect' with inline options), maxExpiryDays (number, min: 1), redactFields (widget: 'string-tags'), eligibility (type: 'code', language: 'expression') the access / lifecycle composite face; enabled the enable toggles; the two lists the multiselect objectui derives for an array of enum (the derived appearance.allowedVisualizations on the view and page forms); redactFields the highlightFields row; eligibility the fields.visibleWhen predicate rows
object.userActions object.form.ts, Advanced, under managedBy type: 'composite': create, import, edit, delete (widget: 'json'), exportCsv (switch) the composite face; the four union keys the G1a requiredPermissions row (json on a union); exportCsv the enable toggles
field.inlineColumns field.form.ts, Configuration, between inlineTitle and inlineAmountField, gated data.type == 'master_detail' like both type: 'repeater' over a curated subset: name (text, required), label (text), width (number), defaultHidden (switch) the fieldGroups repeater face; the gate copies its two sibling rows

Shapes (dispatch assumption 2), confirmed on this base: activityMilestones is z.array(strictObject(…)) at object.zod.ts:2093, four keys; publicSharing is a strictObject at :2286, six keys; userActions a strictObject at :1769, five keys; inlineColumns is z.array(InlineGridColumnSchema) at field.zod.ts:1460, the item schema at :890, twenty keys. The gate's own keysOf read the same sets.

Ledger: one nested subset row at field / inlineColumns in metadata-form-zod-reconciliation.test.ts. Its shape is the object / fields subset row at the top of the ledger (and its two depth-two children fields.options, fields.summaryOperations), which is the ledger's subset precedent. The other three keys need no row: every key they declare is offered.

Row titles: the two new repeaters' row schemas carry a JSON Schema title on every property (Row titles below).

Faces that needed a reason

  • activityMilestones.field pins widget: 'text'. Read at the .objectui-sha pin on main, dd3f7e1b: with no widget, SchemaForm's resolveFieldWidget runs its name conventions, and detectFieldRefWidget turns a string property named field into the field-ref picker whenever widgetContext.objectFields is present. ResourceEditPage always hands that over as a load state, and on an object draft it is idle (the draft names no object / objectName / data.object / interfaceConfig.source). FieldRefWidget then renders a select offering only "None", so a new milestone could not name its field. An explicit widget skips the conventions, and text is a passthrough hint, so the face is a plain input.
  • redactFields pins widget: 'string-tags' for the same reason: a string list named ...Fields becomes field-multi by the same convention.
  • userActions.create / import / edit / delete take widget: 'json', the ruling's union rule (「Union-typed values take json」). Each is a boolean or a strict { enabled, visibleWhen, disabledWhen } object. At the pin, json is a passthrough hint: resolveFieldFace picks the stored value's union branch. A new entry or a stored boolean renders the switch (the first arm), and a stored object renders its three keys as a nested form, whose setField merges each edit into it. No face writes one arm over the other. The object arm is written in source and edited here once stored.
  • allowedAudiences / allowedPermissions take widget: 'multiselect' with inline options. Every member is a spellable option value. MultiSelectWidget writes undefined when every choice is cleared, so the form cannot store the empty list getPolicy reads as "any audience".
  • The objectui faces re-checked here are the ones G2a did not use, read at dd3f7e1b (G2a read the repeater face at f8a9d0fb): the declared composite (CompositeField, pickSubSchema reading properties[NAME] after inlineSchemaRefs), the multiselect widget, the switch and number branches of the scalar chain, and the json hint on a union sub-row. Code readings only, no browser run.

inlineColumns: the curated subset

  • Offered: name, plus the three keys that apply to a column of any type, label, width and defaultHidden. An entry that names only a field is what the key's own describe recommends, because objectui's hydrateColumns completes it from the child field.
  • Deliberately not offered (recorded in the subset row):
    • type: declaring it opts the column out of that hydration.
    • options, reference, displayField, idField, autofill, multiple, accept, prefix, step, scale, computed, expr: each applies to one cell type only. A column takes its type from the child field at render, and no sub-row visibleWhen here can see it, so each would be offered on every column.
    • required, readonlyWhen, requiredWhen: hydration copies them from the child field, where the rule the server enforces lives.
  • The nested reading below shows the row is load-bearing: without it, the gate's zodOnly for field.inlineColumns is exactly those sixteen keys.

Where a misspelt field name is refused, read from the code

The ruling's 「a misspelling is refused loudly at parse」 does not hold for three of this flight's four name positions. Each help text claims only what is measured.

position parse publish door / os validate runtime with a miss help text claims
publicSharing.redactFields[] accepts refused: validate-object-field-refs owns it at error (runtimeTypes includes object); probe below the redaction never binds (fails open) "refused at publish"
activityMilestones[].field accepts (probe) not judged: validate-object-field-refs leaves it out by name; probe below matchMilestone compares after[field] === value, so the milestone never fires "Nothing checks it when you save or publish … never fires"
a {token} in activityMilestones[].summary accepts not judged renderMilestoneSummary renders it empty "a token that names no field renders empty"
inlineColumns[].name accepts (probe) not judged; probe below hydrateColumns leaves an unknown name unhydrated, a plain text column "Nothing checks it when you save or publish … renders a plain text column"

Seat 2's #20479 (for #20432), which landed on main as 4b2d9041 during this flight, extends validate-object-field-refs to four field-level lists and indexes[].fields. Read on origin/main, its list positions still do not include activityMilestones[].field or inlineColumns[].name, so these texts stay true (Out-of-scope finding below).

Other help-text claims, each read from its consumer

  • activityMilestones: an update that moves the field into the value writes the summary in place of the field-change entry, and the first match wins (audit-writers.ts matchMilestone). The comparison is strict, and value is a string, so a milestone on a number or boolean field never fires. A lookup, master-detail or user token shows the referenced title (REFERENCE_FIELD_TYPES). An unset type is updated: the update branch starts from activityTypeFor('update') and a milestone replaces it only when it names one. (The schema's describe says the default is "completed"; see Acceptance notes.)
  • publicSharing (share-link-service.ts): enabled is re-read on every redemption; unset audiences default to ['link_only'] and permissions to ['view']; createLink refuses any other with 422. Every audience still needs the token: resolveToken adds a signed-in check for signed_in and an allowlist check for email. maxExpiryDays defaults to 365, and a link created without an expiry is stored with none (expiresAt ?? null), so the cap does not force one. eligibility binds record, is checked at mint and at every redemption, and a predicate that does not compile or faults refuses.
  • userActions (resolveCrudAffordances): the per-bucket defaults in the help text are CRUD_AFFORDANCE_DEFAULTS verbatim. On an engine-owned or append-only object, turning a verb on also passes plugin-security's assertEngineOwnedWriteAllowed, so users can make that write through the data API.
  • inlineColumns: read only when the field sets inlineEdit (attachInlineSubforms). Unset, deriveColumns curates past six columns into the column chooser. defaultHidden never hides a required column (GridField: c.defaultHidden && !c.required).

Row titles (admitted by the claim from the start)

  • The guard. repeater-item-titles.test.ts (spec: every repeater item schema except dashboard header.actions still has no JSON Schema title, so 21 property-panel tables render machine keys in every locale #17232) requires a JSON Schema title on every authorable property of every repeater's row schema, and forbids a ledger entry. Both new repeaters are new carriers: object:activityMilestones and field:inlineColumns.
  • The change. 24 .meta({ title }) calls, and nothing else in either file:
    • object.zod.ts, the activityMilestones entry: field 'Field', value 'Value', summary 'Summary', type 'Type'.
    • field.zod.ts, InlineGridColumnSchema, all twenty properties: Name, Label, Type, Width, Required, Options, Prefix, Step, Reference, Display Field, ID Field, Multiple, Accept, Default Hidden, Computed, Expression, Scale, Autofill, Read-only When, Required When.
    • The four offered sub-rows' titles equal their declared labels.
  • Byte proof. Stripping exactly the added calls line by line gives each file's base blob byte for byte: object.zod.ts sha256 prefix 8979b5feea7ed0ff both ways (4 removed), field.zod.ts 24713de3b50d5f71 both ways (20 removed).
  • Reverse verification. Run through scripts/ablation-replace.mjs in wrap mode, on the committed state. Deleting the Summary title reads object:activityMilestones … expected [ 'summary' ] to deeply equal [], 1 failed of 29. Deleting the Default Hidden title reads the same for field:inlineColumns with [ 'defaultHidden' ]. The tool proved each mutation landed (anchor 1 → 0, blob changed) and each restore (blob equals HEAD, git diff HEAD empty).
  • No accept set moves. check:generated reads all 15 artifacts up to date on this head, check:authorable-surface and check:api-surface included.

Residue of the reconciliation gate (dispatch assumption 1)

The test file's own helper block was copied verbatim into a probe that was never committed, and run with the gate's own functions. At base it is lines 1-838, sha256 prefix 5e04d44fc5c5edb3, the prefix G2a read. On this branch it is lines 1-851, and it differs from the base block only by the 13 inserted ledger lines. Residue = offerable root keys − offered − root omit rows, per type, with view apart.

Controls, asserted inside the probe: lit, name is offered by 17 of 17 forms; dark, object.zzFabricated19332G2b and object.name are in no residue.

tree residue per type view
base e956924e 4 object 3, field 1 42
this branch (82c5b111, forms and ledger as on the head) 0 none 42

Removed: object.activityMilestones, object.publicSharing, object.userActions, field.inlineColumns. Added: none.

Nested reading on the branch, through the gate's own reconcileNestedLists:

  • field.inlineColumns reads zodOnly = [] with the subset row, and without it zodOnly = accept, autofill, computed, displayField, expr, idField, multiple, options, prefix, readonlyWhen, reference, required, requiredWhen, scale, step, type.
  • object.activityMilestones, object.publicSharing and object.userActions read formOnly = [] · retired = [] · zodOnly = [] with or without any row of their own.

Pins moved (measured, mechanical)

file pin from → to why
object-collapsed-sections-echo-decisions.test.ts collapsed-section leaves / advanced 69 → 105 / 60 → 96 three new Advanced rows with fifteen sub-rows: 18 rows, 36 leaves
object-lifecycle-panel-echo-decisions.test.ts translated .label control, per locale 634 → 657 23 new row labels
field-panel-echo-decisions.test.ts the field form's repeater row properties / walked parents 6 → 10 / ['options'] → ['options', 'inlineColumns'] the new inlineColumns repeater and its four children, all translated
packages/lint/src/validate-predicate-path-refs.test.ts (admitted by the claim's 17:27Z amendment) predicates / literal comparisons 81 → 82 / 56 → 57 the one new predicate, field :: inlineColumns on data.type == 'master_detail'. Measured, not inferred: the shipped corpus, keyed FORM::FIELD::SOURCE, was enumerated at the merge base e956924e (81 predicates, 56 comparisons) and on this branch (82, 57), and the difference is exactly that one entry added and none removed

Verification

Test runs went through scripts/pm/os-verify-lock.sh. The table is the first round's, at 66b73be5, and the lint row is the patch round's, at 16037890. After the merge and the G2a text correction, these re-ran at the final head 2bcad436: pnpm --filter @objectstack/spec test Test Files 572 passed (572) · Tests 16791 passed \| 1 todo (16792); pnpm --filter @objectstack/platform-objects test Test Files 55 passed (55) · Tests 911 passed (911) (the text change moves no pin); lint validate-predicate-path-refs.test.ts + validate-object-field-refs.test.ts 2 files, 114 passed; pnpm check:i18n OK (9 packages in sync) after the three translated leaves were authored and a second --write left no source-hash row; pnpm --filter @objectstack/spec check:generated All 15 generated artifacts are up to date.

run result
pnpm --filter @objectstack/spec test Test Files 569 passed (569) · Tests 16698 passed | 1 todo (16699)
pnpm --filter @objectstack/spec test:repo Test Files 38 passed (38) · Tests 690 passed (690)
repeater-item-titles.test.ts + metadata-form-zod-reconciliation.test.ts repeater-item-titles.test.ts 29 + metadata-form-zod-reconciliation.test.ts 57: Test Files 2 passed (2) · Tests 86 passed (86)
pnpm --filter @objectstack/platform-objects test Test Files 55 passed (55) · Tests 911 passed (911) (before the pin moves: 4 failed, the three pins above)
pnpm --filter @objectstack/spec typecheck / platform-objects typecheck exit 0 both; check:test-typecheck: OK (53 file(s) / 251 error(s) / 138 pinned; 1 / 3 / 2)
pnpm check:i18n check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)
pnpm --filter @objectstack/spec check:generated All 15 generated artifacts are up to date
metadata-protocol src/protocol.meta-types-*.test.ts 4 files, 58 passed
cli unit test/i18n-coverage.test.ts, test/i18n-duplicate-demand.test.ts 2 files, 27 passed
lint src/validate-predicate-path-refs.test.ts at 16037890 Test Files 1 passed (1) · Tests 54 passed (54) (2 failed before the pin move, the two pins above)

Catalogues: node scripts/check-i18n-bundles.mjs --write regenerated the 46 en leaves (23 rows, a label and a help text each). The 138 translated leaves were then authored in zh-CN, ja-JP and es-ES, with no en echo. A second --write kept every translated value and left no source-hash row.

Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 87 commands at the final head 2bcad436 (change set vs merge base 9801da12, after the merge e809f0bd: the 14 files of this diff). That is the first round's 86 plus check:docs-transcript-drift, which the lint test file brings in. All 87 ran on that head, and each exit code went to disk before it was read. In every round pnpm check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: nine packages had no dist/ in the fresh worktree); later gates in the same run built them, and the rerun exited 0 (104 published require entry point(s) across 66 package(s) load), which is the code ran.list records. --ran reports: 87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED (a DERIVED zero — all 87 recorded an exit code and none of them is 3).

Reach probe (built @objectstack/spec and @objectstack/lint of this tree, never committed): an object with activityMilestones: [{ field: 'statsu', … summary: 'Done: {titel}' }] and publicSharing.redactFields: ['titel'], and a child master_detail field with inlineColumns: [{ name: 'quantiy' }]. ObjectSchema.safeParse and FieldSchema.safeParse both succeed. The reference-integrity suite, which the publish door and os validate run, returns exactly one finding, object-field-ref-unknown @ objects[0].publicSharing.redactFields[0] (the lit control), and none for the milestone field, the token or the column.

Acceptance notes

  • activityMilestones[].type's describe says the default is "completed". The runtime writes updated: the update branch's activityTypeFor('update'), replaced only by a milestone that names a type. The help text states the runtime. The showcase milestone names type: 'completed' explicitly, so no measured author relies on the describe. Carrier: none.
  • The public audience's TSDoc (object.zod.ts) says "search engines may index; no token check". resolveToken has no branch for public: it redeems like link_only, token required. The option label says only "Public", and the help text says every audience needs the link. Carrier: none.
  • maxExpiryDays does not force an expiry. A link created without one never expires. That matches the key's describe ("Reject links with expiry beyond this many days"), and the help text says it outright. Whether a capped object should require an expiry is a product question. Carrier: none.
  • An untouched userActions switch reads off even where the managedBy default offers the entry, a switch having no unset state. The composite's help text names the defaults. Carrier: none.
  • Existing object-form rows named field meet the same field-ref convention. lifecycle.ttl.field has type: 'text' and no widget, so by the reading above it renders the "None"-only picker on an object draft. This is a code reading at dd3f7e1b, not browser-run, and it is outside this flight's rows. (fields.summaryOperations.field sits inside the fields row, which the Studio object page hides as canvas-owned.) Carrier: none.
  • Concurrency. origin/main was merged once, with scripts/pm/os-regen-merge.sh, at 9801da12 (e809f0bd), because spec(ui)+objectui: declare the console's round-trip keys on the stored view overlay (#20051 stage ii, ruling 甲) #20456's e967cbd2 edited three view why texts in the reconciliation ledger. It merged without conflict, main's side was taken for every generated artifact it moved, and check:generated then read all 15 up to date. origin/main has moved since (to 3062e500), not onto a file of this diff. Seat 2's fix(spec): hook condition row declares expression, not javascript #20475 regenerates en.metadata-forms.generated.ts too and is not on main yet: ordinary concurrency.
  • G2a's indexes.fields help text went stale when fix(lint)!: object-field-ref-unknown judges a field's relatedListColumns, lookupColumns, lookupFilters and dependsOn, and indexes[].fields #20479 landed, and is corrected here (Status, second paragraph), under the claim's 17:51Z amendment. No other G2a row changes.

Out-of-scope finding (folded into #20432 by the seat; not filed by this run)

  • class c · reach: the save door and the publish door, measured (probe above). activityMilestones[].field and inlineColumns[].name name fields of the owning object, and no authoring door judges them. A misspelt milestone field silently never fires, and a misspelt column renders as plain text. validate-object-field-refs leaves the first out by name. Its extension fix(lint)!: object-field-ref-unknown judges a field's relatedListColumns, lookupColumns, lookupFilters and dependsOn, and indexes[].fields #20479, landed as 4b2d9041, reaches four field-level lists and indexes[].fields, but neither of these.
  • Dedupe words: activityMilestones field unknown · inlineColumns name unknown field · milestone never fires misspelt field · inline grid column reference integrity.

Generated by Claude Code

…d inlineColumns form rows

Flight G2b of ruling record 5861442317: the object form gains the
activityMilestones repeater (4 sub-rows), the publicSharing composite
(6 sub-rows) and the userActions composite (5 sub-rows); the field form
gains the inlineColumns repeater over a curated subset (4 of 20 keys),
with its nested `subset` row in the reconciliation ledger. The two new
repeaters' row schemas carry a JSON Schema title on every property
(24 `.meta({ title })` calls, nothing else in either *.zod.ts).

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
The extractor regenerated the 46 en leaves (23 rows, a label and a help
text each); the 138 zh-CN / ja-JP / es-ES leaves are authored, and a
second --write kept them and left no source-hash row. Two help texts
reworded before translation.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
object collapsed-section leaves 69 -> 105 (advanced 60 -> 96: three new
rows with fifteen sub-rows, 36 leaves); the translated-label control
634 -> 657 per locale (23 new row labels); the field form's repeater
row properties 6 -> 10 with `inlineColumns` a second walked parent.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/spec, touching 19 documentable anchor(s).

27 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 3062e500150c7a230be9344f9c9e17cc173bc095.

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

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 137 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 3062e500150c7a230be9344f9c9e17cc173bc095 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 58678954985b174c164ac4991d32d13681b2f2eb — the merge of head 2bcad436f3efb86e4390a565efd72e814cd942e9 into base 3062e500150c7a230be9344f9c9e17cc173bc095, 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 58678954985b174c164ac4991d32d13681b2f2eb && git checkout 58678954985b174c164ac4991d32d13681b2f2eb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3062e500150c7a230be9344f9c9e17cc173bc095 2bcad436f3efb86e4390a565efd72e814cd942e9 && git checkout -B drift-repro 3062e500150c7a230be9344f9c9e17cc173bc095 && git merge --no-ff 2bcad436f3efb86e4390a565efd72e814cd942e9

node scripts/docs-audit/affected-docs.mjs --json 3062e500150c7a230be9344f9c9e17cc173bc095

⚠️ 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 3062e500150c7a230be9344f9c9e17cc173bc095 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

82 predicates (81 + the field form's inlineColumns gate,
data.type == 'master_detail') and 57 quoted-literal comparisons. The
corpus was differenced against the merge base e956924 by
form::field::source: one added, none removed.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
…dded

Since object-field-ref-unknown judges indexes[].fields, publishing and
os validate refuse an index column that names no field of the object; a
draft save (the schema parse) still does not check. A real field that is
not a stored column (a formula) is still skipped whole by the SQL driver
with a warning. The help text, its comment and its four catalogue
leaves now say exactly that.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2bcad436f3efb86e4390a565efd72e814cd942e9
Local-runs: none

Inputs: card #19332 (body + all 25 comments: ruling 5861442317, claim 5873857698 with both in-place amendments, dev reports 5875120221 / 5875500507 / 5876094983, the G1b/G2a records), PR #20485 (body, 14-file list, net diff vs main at the head), the head's check-runs; the fold comment 5875171759 on #20432 and the G2a record 5872805830 on PR #20449, both named by the brief. The API diff and git diff 9801da12..2bcad436 carry identical changed lines (698 each, the same 14 paths, +628/−42). Branch commits: 82c5b111 → 49ad159a → 917c809f → 66b73be5 → 16037890 → e809f0bd (merge of 9801da12) → 2bcad436, first parent rooted at e956924e on main; 7 commits, as the PR says; no rebase. objectui read at the head's .objectui-sha dd3f7e1b (also origin/main's) in the sibling checkout, with git show only.

Check-runs on the head (the gate verdicts): 42 completed — 37 success, 5 skipped (Auto Label, Check PR Size, Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failures. All five skips are rows of scripts/pm/check-expected-skips.mjs at the head (read, not run). Green includes Check Changeset, Lint & Repo Gates, Test Core 1–6 and the aggregate, Build Core, Spec property liveness, Type Check ×4, Governed Surface Queue Guard, all three claim guards.

git merge-tree --write-tree --name-only origin/main 2bcad436 (origin/main = fc0db22b, five commits past the merge base 9801da12): clean, tree 6f3f96e5c83e7be400ea910a7c640a880cb64d33, exit 0, no conflicted path listed.

① Derived judgments

  1. object.form.ts — three rows in advanced, each with hand-written sub-rows, as ruled. Right.

    • activityMilestones, directly after validations, type: 'repeater', four sub-rows against z.array(strictObject(…)) at object.zod.ts:2093 (exactly four keys): field text-widget + required ↔ z.string() required; value text + required ↔ z.string(); summary text + required ↔ z.string(); type text ↔ z.string().optional(). Mirror: the fieldGroups repeater face (:88-105, declared and labelled sub-rows), text rows as in Basics. widget: 'text' on field is a measured necessity at the pin: SchemaForm.tsx:875 runs detectFieldRefWidget only when fieldSpec.widget is unset; :532-556 returns field-ref for a string prop named field whenever widgetContext.objectFields is present in ANY load state (idle included — the comment says so), and ResourceEditPage.tsx:995 always hands it over as mapLoaded(objectCatalogState, …). text is in KNOWN_PASSTHROUGH_WIDGETS (:230), so the face is the plain string input.
    • publicSharing, directly after requiredPermissions, type: 'composite', six sub-rows against the strictObject at :2286 (exactly six keys): enabled boolean ↔ z.boolean().default(false); allowedAudiences multiselect with inline options public / link_only / signed_in / email ↔ z.array(z.enum([...])) of the same four; allowedPermissions multiselect view / comment / edit ↔ the same enum; maxExpiryDays number min: 1 ↔ z.number().int().positive(); redactFields string-tags ↔ z.array(z.string()); eligibility type: 'code' / language: 'expression' ↔ a plain CEL string. Every option value matches FormSelectOptionSchema's identifier rule (underscore admitted). Mirrors as the body names: the access / lifecycle composites (:565, :677), the enable toggles (:408), highlightFields (:60), fields.visibleWhen (:390). multiselect is a registered widget (widgets.tsx:2951) that prefers fieldSpec.options, and objectui derives that very widget for an array of enum (SchemaForm.tsx:405), the appearance.allowedVisualizations face. redactFields pinning string-tags is the same convention escape: /Fields$/ on a string array becomes field-multi (:551).
    • userActions, directly under managedBy, type: 'composite', five sub-rows against the strictObject at :1769 (exactly five keys): create / import / edit / delete widget: 'json' ↔ z.union([z.boolean(), RowCrudActionOverrideSchema]); exportCsv boolean ↔ z.boolean().optional(). The ruling's union rule (json, never a one-arm face) is honoured, and the face claim holds at the pin: json is not in WIDGETS but is a passthrough hint, so resolveFieldFace (:668-740) resolves the stored value's union branch — a new entry or a stored boolean scores the boolean branch (scalar switch), a stored object the object branch (nested form). Mirror: G1a's requiredPermissions widget: 'json' (:585).
  2. field.form.ts — inlineColumns in Configuration, between inlineTitle and inlineAmountField, gated data.type == 'master_detail' exactly like both siblings. Right. type: 'repeater' over four of InlineGridColumnSchema's twenty keys (field.zod.ts:890, the node z.array(InlineGridColumnSchema) at :1460): name text + required ↔ z.string().min(1) required; label text ↔ optional string; width number ↔ z.number().positive().optional(); defaultHidden boolean ↔ z.boolean().optional(). Mirror: the fieldGroups repeater face.

  3. Reconciliation ledger — one nested subset row at field / inlineColumns. Right, and load-bearing. Shape is the SubsetEntry type exactly (kind, type, path, why) and the object / fields precedent at the top of the ledger. reconcileNestedLists (:828-850) reads isSubset and empties zodOnly for that path; without the row it would be subKeys − offered − retired = the sixteen keys the why names, and they are the right sixteen: type (1) + the twelve one-cell-type keys + required / readonlyWhen / requiredWhen (3) = 20 − 4. Each reason verified at the pin (plugin-form/src/deriveMasterDetail.ts): hydrateColumns returns a column untouched when it declares type (:298), and copies required, readonlyWhen, requiredWhen, options, reference and the computed expr from the child field (:304-323). The net diff to this file is the 13-line insertion alone.

  4. object.zod.ts and field.zod.ts — 24 .meta({ title }) calls and nothing else. Right, byte-proved. The diff of both files is 24 minus/plus line pairs; stripping exactly one appended .meta({ title: '…' }) from each plus line reproduces its minus line, 24 of 24, no other changed line (the whole-file hash the dev quoted is the same proof; a blind strip differs only because G2a's earlier titles are in the base too). Titles: activityMilestones Field / Value / Summary / Type; InlineGridColumnSchema all twenty, and the eight offered sub-rows' titles equal their declared labels. The spec: every repeater item schema except dashboard header.actions still has no JSON Schema title, so 21 property-panel tables render machine keys in every locale #17232 guard (repeater-item-titles.test.ts) requires a title on every row property of every repeater and forbids a ledger entry; both new carriers are inside the green Test Core. The accept set does not move: .meta() is a JSON Schema annotation with no parse effect; no export changes; no committed artifact carries a title (git grep 'Default Hidden' at the head hits only field.zod.ts, field.form.ts and the en catalogue label); check:generated, check:authorable-surface and check:api-surface are inside the green Lint & Repo Gates.

  5. Catalogues — 46 en leaves regenerated, 138 translated leaves authored. Right. 23 rows × (label + helpText); every en helpText equals its form text verbatim (all 23 compared). zh-CN / ja-JP / es-ES are translations, not echoes, and carry the same claims the English does — the no-refusal sentence on activityMilestones.field and inlineColumns.name, the refusal-at-publish on redactFields, 365, updated, the six-column chooser, the required-column rule. The corrected indexes.fields leaf carries all three clauses in all three locales. check:i18n is inside the green Lint & Repo Gates.

  6. Pins — every move mechanical and complete.

    • object-collapsed-sections-echo-decisions.test.ts: 69 → 105 and advanced 60 → 96, both +36 = 18 rows × 2 (1+4, 1+6, 1+5); capabilities 9 untouched.
    • object-lifecycle-panel-echo-decisions.test.ts: 634 → 657 = +23 labels (18 object rows + 5 field rows).
    • field-panel-echo-decisions.test.ts: ROW_PROPERTIES 6 → 10 (+4, inlineColumns.defaultHidden asserted), walked parents ['options'] → ['options', 'inlineColumns'].
    • packages/lint/src/validate-predicate-path-refs.test.ts (the 17:27Z amendment): 81 → 82 and 56 → 57. Mechanical: the diff adds exactly one visibleWhen to the shipped forms — inlineColumns on data.type == 'master_detail' — and no other new row or sub-row carries a predicate (read off the two form diffs); it is an == against a single-quoted literal, so both counters move by one. The file's diff is +14/−2: the two expect lines and their history comments in G1b's form (ec292cf5). Nothing else under packages/lint/** is in the file list; validate-object-field-refs.ts is untouched.
  7. Amendment 17:51Z — indexes.fields help text, its comment and four leaves. Right against main. Each clause of the new sentence measured on origin/main: save — IndexSchema.fields is z.array(z.string()), the parse judges no name; publish and os validate — reference-integrity-suite.ts:351 registers validateObjectFieldRefs with runtimeTypes: ['flow', 'object'], so it runs on the object publish door and in the validate command, and validate-object-field-refs.ts:574-590 reports indexes[i].fields[j] as object-field-ref-unknown at error since 4b2d9041; sync — sql-driver.ts:14413-14424 missing = the declared columns not in physicalColumns → logger.warn('[sql-driver] skipping declared index … column(s) not materialized') + continue, the whole index, and the docblock names a virtual formula field as the case the rule's own header leaves to the driver. The comment hunk above the row is a necessary consequence, not a breach: the old six lines said "No authoring door judges its names: not the schema parse, not the publish door, not os validate (validate-object-field-refs leaves it to the storage layer by design) … so the help text claims that and no refusal", which is false on main and would contradict the corrected text five lines below it; the rewrite is the same row's own comment, carries no behaviour, no leaf and no other row, and the dev declared it. No other G2a row, sub-row or comment moved: the object.form.ts hunks are exactly the three inserts, the indexes comment and the indexes.fields helpText; fieldGroups, indexes.name and indexes.unique are byte-identical to the base.

  8. Help text against its readers. Every claim holds; no text promises a refusal or a behaviour no reader honours.

    • activityMilestones row: matchMilestone (audit-writers.ts:649-664) returns the first entry with after[field] === value && before[field] !== value, and the update branch (:1521-1540) writes that summary in place of the tracked-change summary. field: no door — z.string(), the lint rule's header lists activityMilestones[].field under NOT owned and its slots on origin/main are relatedListColumns / lookupColumns / lookupFilters / dependsOn / indexes[].fields / highlightFields / redactFields; a miss reads after['statsu'] undefined and never fires. value: strict === against a z.string() value; ledgerView (:1202-1229) only masks credential fields and never stringifies, so a number or boolean field never matches. summary: renderMilestoneSummary (:752-768) takes after[key], resolves lookup / master_detail / user through REFERENCE_FIELD_TYPES to the referenced title, and renders an unknown token as ''. type: activityTypeFor('update') is updated, replaced only if (milestone.type); completed is in SYS_ACTIVITY_BUILTIN_TYPES (feed.zod.ts:93-101) and the column is an open vocabulary by the [Decision] Is sys_activity.type a closed platform vocabulary or an author-extensible one? Both readings are true of the code today #11507 ruling, so "a built-in kind such as completed, or your own word, stored as written" is exact. (The schema's own describe still says "default completed" — see ③.)
    • publicSharing row and sub-rows (share-link-service.ts): createLink refuses 422 while enabled is off (:459); resolveToken re-reads the block on every redemption and returns null when off (:711-720), and getPolicy returns empty lists when off, so "nothing else here applies". allowedAudiences unset → ['link_only'] (:129), any other 422 (:477-481); the token is looked up before any audience gate (:631), then signed_in needs probe.signedInUserId and email an allowlist match (:643-648); public has no branch, so "every audience still needs the link itself" is what the code does. allowedPermissions unset → ['view'], other 422 (:468). maxExpiryDays unset → DEFAULT_MAX_EXPIRY_DAYS = 365 (:75, :537); normaliseExpiresAt(undefined) returns null and redemption checks row.expires_at && …, so a link minted without one never expires. redactFields is applied as object policy ∪ per-link set after every gate (:781-783), the TSDoc scopes it to kind:'share-link' principals, and validate-object-field-refs owns publicSharing.redactFields[] at error on the object publish door — the refusal claimed exists. eligibility: compile failure, fault and false each refuse the mint with 422 (:283-326), and stillEligible returns null at redemption (:778). Precision note, not a defect: the row's "no link can be created" excludes the system-context / permissive mint bypass (:459), which is not an author-facing door.
    • userActions: the per-bucket defaults are CRUD_AFFORDANCE_DEFAULTS (object.zod.ts:3037-3044) verbatim; exportCsv is true in every bucket; assertEngineOwnedWriteAllowed (system-write-guard.ts:109-133) returns when resolveCrudAffordances(schema)[need] is true, so an opened verb on an engine-owned / append-only object is writable through the data API; the once-per-toolbar / per-row scopes are RowCrudActionOverrideSchema's TSDoc. The untouched-switch sentence is a face reading (a switch has no unset state), stated rather than hidden.
    • inlineColumns: attachInlineSubforms skips a field without inlineEdit (MetadataProvider.tsx:514); deriveColumns curates past DEFAULT_MAX_INLINE_COLUMNS = 6 into defaultHidden (deriveMasterDetail.ts:164, :275); hydrateColumns leaves an unknown name as-is with the comment "grid falls back to text" (:300); label unset → the field's label (:303); GridField.tsx:685-687 hides only c.defaultHidden && !c.required, and hydration copies required from the field. name: no lint slot reads inlineColumns, and FieldSchema judges nothing beyond min(1).
  9. Changeset prose names the four rows and their faces, the subset row and its three reasons, the refusal map (redactFields refused; the three other positions not), the 24 titles, the 46 leaves, and that the top-level zodOnly direction stays unwired — all true of the diff.

② Semver level

@objectstack/spec: minor, @objectstack/platform-objects: patch — the same pair as G1a (7db1332f), G1b (ec292cf5) and G2a (dc0ab6a2), for the same publish: a larger getMetaTypes() form payload and new catalogue keys, no export change. The 24 titles do not raise the level (G2a's 12 landed under the same pair on the same reasoning). Clause-②: no holds on the head: the accept set of ObjectSchema / FieldSchema / InlineGridColumnSchema is unchanged (pairwise proof in ①4); title on the served JSON Schema is shipped bytes, not the published accept set (contract-review.md: 出货字节, ⛔ 不是已发布接受集); METADATA_FORM_REGISTRY is an opaque Readonly record. No (widening) / (narrowing) arm, correctly; the changeset and the PR body both carry the line. Check Changeset is green.

Clause-②: no

③ Boundary flags

Dev flags, in report order:

  • 5875120221 open_questions[0] (admit the lint census pin) — answered by the seat with option A; claim 5873857698 amended in place (its 17:27Z line); 16037890 carries exactly the two numbers and their comments (①6).
  • Probes in the issue worktree, restored; check:dual-build-cjs-loads first exit 3 then 0; suites at 66b73be5 / 917c809f rather than the head — process notes with no diff effect; the head's own check-runs are the verdict in every case.
  • Composite sub-rows declare a label where the G1 composites declare none — FormFieldSchema admits it, the leaf is what the pins count, and it is what the repeater rows already do; not a defect.
  • 5875500507: base corpus read in a scratch worktree; other suites not re-run — process; head's check-runs govern.
  • 5876094983: the indexes comment hunk — judged a necessary consequence of the admitted correction (①7). The merge — e809f0bd has parents 16037890 and 9801da12, and its tree equals git merge-tree --write-tree 16037890 9801da12 byte-exact (dcfa5f42…), so no hunk, generated or otherwise, was hand-resolved. PR body edited beyond the one sentence — the added lines are true of the merge and the head; no diff effect.

The brief's five claims:

  1. Residue 4 → 0 — consistent with the diff: G2a's record left exactly these four keys, the diff offers all four at the root and adds no other root field:. A carried measurement (the top-level direction is unwired), not a gate verdict; not re-run here.
  2. No authoring door on activityMilestones[].field / inlineColumns[].name — verified on origin/main after 4b2d9041 (①8); each help text says "Nothing checks it when you save or publish" and names the runtime consequence, and redactFields alone claims the refusal that exists. The fold is 5875171759 on [finding] a misspelt field name in a field's relatedListColumns, lookupColumns, lookupFilters[].field or dependsOn passes every authoring door, and fails only at view or picker time #20432 — "⛔ Not a new card. ⛔ Not a claim", the family's enumeration only — the right shape.
  3. Pins and translations — all mechanical and complete (①6); the three locales carry the English meaning (①5).
  4. One merge, no hand-resolved generated hunk; nothing of e967cbd2 — e967cbd2 is an ancestor of the merge base 9801da12, its only ledger hunk sits at base lines 304-324, and the net diff's only ledger change is the +13 insertion at 258-270 (①3); automerge equality above.
  5. The two carrier: 承接者:无 notes — (a) a formula column in an index passing the rule and skipped at sync: already carried, by [finding] a misspelt field name in a field's relatedListColumns, lookupColumns, lookupFilters[].field or dependsOn passes every authoring door, and fails only at view or picker time #20432 step 2 (its landing record 5875310879 folds "a virtual formula column in an index" into step 2, re-routed to domain:engine by 5875602547), so "no new carrier" is right. (b) the bundle of first-round notes: maxExpiryDays not forcing an expiry and the switch-reads-off face are design facts the texts state, no carrier; the public audience TSDoc is a source comment, harmless (fail-closed) and outside this flight. Two of the five do warrant a carrier and are escalated to the seat, not held against this diff: the activityMilestones[].type describe ("default completed") is a served contract sentence that is false on main (the runtime writes updated; a one-word *.zod.ts describe fix outside this claim's byte budget), and lifecycle.ttl.field (type: 'text', no widget) meets the same field-ref convention this flight had to escape, so by the same reading it renders the None-only picker on an object draft — code-read only, a widget: 'text' pin away. Neither blocks: the ruling prescribes these rows, and every text this diff ships is true.

Escalated for the landing seat, not a defect of this diff: origin/main has moved five commits past the merge base (fc0db22b and on), none onto a file of this diff; the merge-tree above is clean.

Implemented-by: claude/issue-19332-g2b-remaining-g2-rows
Reviewed-by: session_01ARcDurZ5j34RdqsGgc4jgH

VERDICT: PASS

veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… help that shipped as English copies (objectstack-ai#20490)

Fixes objectstack-ai#20462

Clause-②: no

## What changed

A zh-CN console showed English on Setup surfaces: the Invite user dialog
listed the membership roles as 所有者 / 管理员 / **Delegated Admin** / 成员, and
a team record read **MEMBER COUNT**. The keys were present in
`packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts`,
but their values were byte copies of the `en` source, as `os i18n
extract --fill=default` seeds them.

- **320 copied leaves translated** in the zh-CN objects bundle. Only
leaf values changed; the structure is untouched. The two reproduced
strings now read 受托管理员 (on both `sys_member` and `sys_invitation`) and
成员数.
- **42 leaves kept in English by design**, each declared with its reason
in a decision ledger, `objects-zh-cn-echo-decisions.test.ts`. The list
is below.
- **The provenance companion** `zh-CN.source-hashes.generated.ts` was
regenerated by `pnpm i18n:extract`, never by hand. It records a leaf
only while the leaf is a byte copy of its source, so 320 rows dropped
and 42 remain. The 42 are set-equal to the declared echoes.
- **Pin:** the reproduced leaves
`sys_member.fields.role.options.delegated_admin`,
`sys_invitation.fields.role.options.delegated_admin` and
`sys_team.fields.member_count.label` are pinned as translated under
zh-CN. The role options must read one word per role on both objects.
- `ja-JP` and `es-ES` are not touched. They carry the same copy class;
their counts are under *Out of scope*.

**The coverage-ratchet half is out of scope, per triage (5873680175).**
Teaching `i18n-coverage-baseline` to count a copied source string as
untranslated widens an existing gate. If the maintainer wants that
ratchet, it is a separate gate card. The new ledger deliberately does
not assert that no other zh-CN leaf is a copy, for the same reason.

## Copy count, before and after, by key class

A zh-CN string leaf counts as a copy when it is byte-equal to the `en`
leaf at the same path, over every leaf of `en.objects.generated.ts` /
`zh-CN.objects.generated.ts`. Both bundles have 1529 leaves.

| key class | before (b810ddb) | after |
|---|---:|---:|
| `label` (object, field, view, action param) | 196 | 27 |
| `options.*` | 20 | 9 |
| `pluralLabel` | 11 | 0 |
| `help` | 114 | 0 |
| `description` | 11 | 0 |
| `placeholder` | 4 | 4 |
| `emptyState.title` / `.message` | 4 | 0 |
| result-dialog field (`client.client_id`, `client.client_secret`) | 2 |
2 |
| **total** | **362** | **42** |
| `label` + `options.*` | 216 | 36 |

The card reads 338 in total, 192 of them `label` / `options.*`, at
3cf6449. The zh-CN and en object bundles are byte-identical between
3cf6449 and b810ddb, and this walk reads 362 / 216 at 3cf6449
too. So the difference of 24 comes from the counting method, and the
card does not state its method.

`help` / `description` / `placeholder` / `emptyState` are decided as the
same class as `label`. The extractor files each of them as an
`ExpectedEntry` under the same `source` kind as its element's label
(`field`, `object`, `view`, `action`;
`packages/cli/src/utils/i18n-extract.ts`), and the coverage detector
consumes them identically. So they were decided as the same class:
`help`, `description` and `emptyState` were translated, and all four
placeholders are declared echoes, because each shows a value the admin
types (see the list below).

## Kept in English by design (42), with reasons

| leaves | value(s) | reason |
|---|---|---|
| `sys_account._actions.link_social.params.provider.options.{google,
github, microsoft, apple, facebook, gitlab, discord}` (7) | Google,
GitHub, Microsoft, Apple, Facebook, GitLab, Discord | Sign-in provider
brands, shown on the link button as the name a user recognises. The
bundle renders no brand name: the authored SSO empty state keeps Okta,
Entra and Auth0 verbatim. |
| `fields.id.label` on `sys_oauth_application`,
`sys_oauth_access_token`, `sys_oauth_refresh_token`,
`sys_oauth_consent`, `sys_oauth_resource`, `sys_oauth_client_resource`,
`sys_oauth_client_assertion`, `sys_sso_provider`, the eight `sys_scim_*`
objects, `sys_email_template`, `sys_metadata`, `sys_metadata_history`,
`sys_view_definition`, `sys_metadata_audit`, `sys_secret`,
`sys_setting_audit` (23) | ID | The bundle keeps the initialism verbatim
in every label that carries it (用户 ID, 团队 ID, 客户端 ID). A bare `ID` has
no noun to render, and adding one would invent content. |
| `sys_oauth_application.fields.jwks.label`, `.jwks_uri.label` (2) |
JWKS, JWKS URI | Protocol names the bundle keeps verbatim: `sys_jwks` is
authored 签名密钥 (JWKS), and URIs stay URI (重定向 URI). Their `help` leaves
are translated. |
|
`sys_oauth_application._actions.create_oauth_application.resultDialog.fields.client.client_id`
/ `.client_secret` (2) | Client ID, Client Secret | The credential names
an admin copies out of the one-time dialog. The same dialog family
authored its zh-CN text with the names verbatim (我已保存 Client Secret, 新的
Client Secret). |
|
`sys_sso_provider._actions.register_saml_provider.params.entryPoint.label`
(1) | IdP SSO URL | Nothing but protocol initialisms the bundle keeps
verbatim. The sibling SAML params are authored IdP 实体 ID and IdP 签名证书. |
| `sys_sso_provider._actions.register_sso_provider.params.{scopes,
mapEmail, mapName}.placeholder` (3) | openid email profile, email, name
| A placeholder shows the literal value the admin types: OIDC scope and
claim names the IdP matches byte for byte. The authored help on the same
params keeps them verbatim (默认为 “email”). |
|
`sys_sso_provider._actions.register_saml_provider.params.identifierFormat.placeholder`
(1) | urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress | The SAML
NameID format URN the admin types: an identifier, not prose. |
| `sys_email.fields.message_id.label` (1) | Message-ID | The RFC 5322
header name. The authored help on the same field keeps it (传输层分配的
RFC-5322 Message-ID). |
| `sys_setting_audit.fields.source.options.{ui, api}` (2) | UI, API |
Initialisms the bundle keeps wherever they appear (API 密钥, 在 UI 中暴露). |

**Where the declaration lives.** Neither the extract config nor `os i18n
extract` / `os i18n check` has a keep-source mechanism: no allowlist, no
flag, no provenance mark. `--filter` only narrows emission. The existing
mechanism that declares a leaf "English by design" is this package's
echo-decision ledger. The shape is `type Verdict = 'translate' |
'echo'`, with a reason on every row and a departure reason required for
every `echo` (`undeclaredEchoes`). It lives in
`packages/platform-objects/src/apps/translations/*-echo-decisions.test.ts`,
one file per family. `report-dataset-panel-echo-decisions.test.ts` is
the first: it has `Verdict`, a reason per row and the per-locale
departure check written inline. The named `undeclaredEchoes` predicate
arrives in the later rounds, first in
`object-field-editor-panel-echo-decisions.test.ts`.
`objects-zh-cn-echo-decisions.test.ts` is that shape applied to the
objects bundle. No new mechanism was added.

## Terminology

The terms follow the zh-CN leaves that were already authored, so one
term is not translated two ways:

- `delegated_admin` is 受托管理员. That is the console's own word for this
role value (objectui `organization.roles.delegatedAdmin`). The other
three roles already read the same in both places (所有者 / 管理员 / 成员). The
alternative is 委派管理员, the in-repo word for the ADR-0090 concept
(委派管理范围). One line changes it if the maintainer prefers it.
- SCIM provisioning is 预配, following
`sys_scim_group_member.display_title` (预配的用户及其所在组). Decommission is 下线.
- The notification leaves take the service-messaging bundle's words: 主题,
严重程度 (信息 / 警告 / 严重), 负载, 去重键 and 通道.
- `managed_by` options take `sys_metadata`'s words: 管理方, 平台 / 包 / 管理员.
- OIDC claims stay `claim`, and help text keeps `scopes`, both as the
bundle already writes them (映射:邮箱 claim; 客户端可申请的 scopes …).
- "My Memberships" is 我的组织, the account nav's label for the same screen.
`Web` is Web 应用, like the sibling
`sys_oauth_application.fields.type.options.web`.
- `sys_migration` keeps its section's ASCII punctuation.

## Collateral: nine existing ledgers encoded the old copy debt

Nine panel ledgers in the same directory asserted floors on the zh-CN
provenance table: more than 100 rows, more than 300, and more than 50
echoing objects leaves "to sample". Those floors held only while about
360 zh-CN objects copies existed, so any card that translated them would
go red. Each floor's own message states its intent, "provenance table is
empty" or "has no echoing objects leaf to sample". Each now asks for
greater than 0, with a one-line comment saying why.

One more test pinned `sys_oauth_resource.fields.access_token_ttl.label`
as an unauthored fill in all three locales. Its own message says "if a
translator authored it, this ledger note is stale". It now records that
zh-CN is authored (访问令牌 TTL keeps the `TTL` token, the treatment the
lifecycle rows give it), while ja-JP and es-ES stay fills.

Files: `action-body-panel-`, `bare-type-display-`, `dataset-panel-`,
`field-panel-`, `hook-execution-panel-`, `object-collapsed-sections-`,
`object-lifecycle-panel-`, `page-interface-panel-` and
`report-form-echo-decisions.test.ts`. PR objectstack-ai#20485 edits three of these
files (`field-panel-`, `object-collapsed-sections-`,
`object-lifecycle-panel-`) in different hunks. A local merge-tree run
was not taken, so whether the two merge cleanly is not measured.

## Verification

Measured at head `08ebcd01d`. That is a true merge of `origin/main`
851af0c, and the CLI closure was rebuilt after the merge.

- **`pnpm check:i18n` (H3):**
  - exit 0 at base;
- exit 1 after the leaf edits alone, reported as `platform-objects
DRIFTED (1)`. The drifted file was the provenance companion;
- exit 0 after `pnpm i18n:extract`. The extract changed only
`zh-CN.source-hashes.generated.ts`: −320 rows, 0 added, and the bundle
file stayed byte-identical.
- **`pnpm --filter @objectstack/platform-objects exec vitest run
--maxWorkers=2`:** 56 files and 921 tests passed. Before the collateral
edit, the same run had 15 failures across 9 files, all of them the
floors above.
- **`pnpm --filter @objectstack/platform-objects typecheck`:** exit 0.
`tsconfig.test.json` includes the new ledger (`--listFilesOnly`: 1 hit).
- **Reverse verification.** Three mutation legs, each through
`scripts/ablation-replace.mjs` on `zh-CN.objects.generated.ts`, each red
as predicted, and each restored to blob `24cfb9355b89` with `git diff
HEAD` empty:
1. `label: "成员数"` changed back to `"Member Count"`: 1 failed / 9 passed,
"sys_team.fields.member_count.label reads its en source again".
2. Both `delegated_admin: "受托管理员"` changed back to `"Delegated Admin"`:
2 failed / 8 passed. The verdict row failed, and so did the role-word
test ("expected 'Delegated Admin' to match /\p{Script=Han}/u").
3. The declared echo `google: "Google"` changed to `"谷歌"`: 1 failed / 9
passed, "is a declared echo and must stay the en source".
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `08ebcd01d` printed 61 commands.
All 61 were run, and each exit code was written to a file before any
pipe.
  - 60 exited 0 on their first run.
- `pnpm check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET
(exit 3: workspace `dist/` missing for packages outside the CLI
closure). That is not a measurement. Rerun once the `dist/` existed:
exit 0.
- `--ran` reconciliation: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN
(exit 0).
- `pnpm check:i18n-coverage`: exit 0, "621 baselined untranslated
string(s), none new". The ratchet is unchanged, as expected.
- NOT MEASURED locally, declared to CI: the 6 workflow-valued families,
the 5 path-scheduled CI jobs and the 4 type-check lanes the derivation
names.

## Patch round 1

The contract review 5876239898 (PASS at `08ebcd01d`) named one false
word and two comment slips. All three are corrected here, as text only;
no translated value, assertion or floor changed.

- **Changeset:** the translated-class list no longer names placeholders.
All four placeholders were decided as echoes, and none was translated.
- **Ledger header** (`objects-zh-cn-echo-decisions.test.ts`, comment
only): it no longer says the provenance table "kept exactly the 42" as
an assertion. It now names what is asserted: every declared echo still
has its provenance row, and no pinned translation has one. That the
table holds nothing beyond the echoes is measured, and deliberately not
asserted.
- **This body:** the H4 sentence now says placeholders were decided as
the same class and are all declared echoes. The ledger-history sentence
now credits `undeclaredEchoes` to the later rounds rather than to
`report-dataset-panel-`.
- **Merge and readings:** `origin/main` fc0db22 was merged with a true
merge commit. At head `927957df4`, `pnpm check:i18n` exited 0 and `pnpm
--filter @objectstack/platform-objects exec vitest run
src/apps/translations/objects-zh-cn-echo-decisions.test.ts` passed 10 of
10 (lock verdict: command-exit 0). The round-0 gate readings above were
taken at `08ebcd01d` and were not re-run.

## Out of scope

- The coverage ratchet (see above).
- `ja-JP` and `es-ES` carry the same copy class, measured at b810ddb
and not edited:
- `ja-JP`: 383 copies (`label` 210 · `options.*` 20 · `pluralLabel` 12 ·
`help` 121 · `description` 12 · `placeholder` 4 · `emptyState` 4);
`label` + `options.*` = 230.
- `es-ES`: 392 copies (`label` 217 · `options.*` 22 · `pluralLabel` 12 ·
`help` 121 · `description` 12 · `placeholder` 4 · `emptyState` 4);
`label` + `options.*` = 239.

## Acceptance notes

- The existing zh-CN bundle renders `Locked Until` two ways, 锁定至 on
`sys_user` and 锁定到 on `sys_job_queue`. This card uses 锁定至 for
`sys_two_factor` and leaves the pre-existing pair alone.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…objectstack-ai#20494) (objectstack-ai#20521)

Fixes objectstack-ai#20494

Clause-②: no

## What changed

- `packages/spec/src/data/object.zod.ts` (~:2123): rewrote the
`activityMilestones[].type` `.describe()` to state the real default — an
unset `type` keeps the update row's kind, `updated`. No schema shape
change.
- Regenerated `content/docs/references/data/object.mdx` with `gen:docs`
(never hand-edited).
-
`packages/plugins/plugin-audit/src/activity-type-vocabulary-enforcement.test.ts`:
corrected the pin's title and docblock only — they stop describing a
divergence and stop saying it was "filed separately" (this card is where
it was filed). Its assertions are byte-for-byte unchanged.
- `.changeset/20494-milestone-type-default-describe.md`
(`@objectstack/spec` patch, `Clause-②: no`).

## Why

`audit-writers.ts` starts `activityType` from `activityTypeFor(action)`,
and a milestone can only fire on the UPDATE branch (`create` / `delete`
return their own summary before the milestone match runs), so an unset
`type` has always emitted `updated`, never `completed`. `milestone.type`
overrides it only when the author actually sets it — that half of the
old describe was correct and is unchanged. The plugin-audit pin
(`activity-type-vocabulary-enforcement.test.ts`) already measured this
real answer; only its title and docblock claimed a divergence and said
the finding was filed separately.

## Dedupe search for other stale copies

A repo-wide grep for the old wording, and for every `activityMilestones`
mention, found no other hand-written copy of the "completed" default.
`object.form.ts`'s `activityMilestones.type` help text (shipped with PR
objectstack-ai#20485 — "Unset: updated.") already states the real default and is
unchanged.

## Verification record

Commands run in this worktree, foreground, through
`scripts/pm/os-verify-lock.sh` where heavy:

- `pnpm --filter @objectstack/spec build` — pass.
- `pnpm --filter @objectstack/spec run check:generated` — all 15
generated artifacts green after `pnpm --filter @objectstack/spec run
gen:docs` (only `check:docs` was stale, for exactly this describe
change).
- `pnpm --filter @objectstack/spec test` — 572 test files / 16796 tests
passed, 1 todo.
- `pnpm --filter @objectstack/spec typecheck` — pass.
- `pnpm --filter '@objectstack/plugin-audit^...' build` (dependency
closure) — pass.
- `pnpm --filter @objectstack/plugin-audit test` — 25 test files / 363
tests passed, including the renamed pin.
- `pnpm --filter @objectstack/plugin-audit typecheck` — pass.
- `pnpm check:doc-authoring` (dispatch-named — no tracker number in the
describe) — pass.
- `node scripts/check-nul-bytes.mjs` — pass.
- `node scripts/check-adr-0087-registration.mjs`,
`check-changeset-no-major.mjs`, `check-empty-changeset.mjs`,
`check-issue-citations.mjs` (`--self-test` and `--base origin/main`,
matched by the new changeset file) — all pass.

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--ran <record>` reconciliation: 112 families derived from this diff's
paths, 102 UNRUN locally. Those are whole-tree/whole-package scans this
describe-text-only change (no export, no authorable key, no schema shape
change) does not plausibly touch — `check:generated`'s own 15-gate sweep
already covers the ones that read the schema/docs pipeline this change
lands in (`check:docs`, `check:api-surface`, `check:authorable-surface`,
`check:export-origins`, `check:declaration-map`,
`check:strictness-ledger`, `check:liveness`, `check:test-typecheck`).
The remaining UNRUN families are deferred to CI's farm per this repo's
local-verification scope.

## Acceptance notes

Nothing found outside this card's file surface.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants