Skip to content

Commit 577a51e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14955-error-refresh-asymmetry
2 parents ebad613 + 2756e07 commit 577a51e

14 files changed

Lines changed: 1034 additions & 22 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
`ReadonlyFieldRejectedError`'s error `code` is now an importable constant.
6+
7+
The strict-readonly refusal — thrown by `engine.update` and `engine.insert` when `options.strictReadonlyWrites` is set and the payload carried caller-supplied fields the engine would have stripped — already told readers to identify it by `code`. `content/docs/kernel/contracts/data-engine.mdx` says so in its own words: *"Catch it by `code`, not `instanceof`, and read `drops` for the per-reason breakdown"*. Until now the code was an inline string literal with nothing to import, so the only way to FOLLOW that published instruction was to re-spell `'ERR_READONLY_FIELD_REJECTED'` in your own package — which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so.
8+
9+
One new export from `@objectstack/objectql`:
10+
11+
- `READONLY_FIELD_REJECTED_CODE``ReadonlyFieldRejectedError`'s ADR-0112 `code`.
12+
13+
**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of the class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary, which is exactly what the documentation has been telling readers to do.
14+
15+
**Nothing about the wire changed.** The constant holds text byte-identical to the literal it replaces; the refusal throws the same `code` and the same message as before. Consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing.
16+
17+
**`ReadonlyFieldRejectedError` itself was already exported and stays exported.** Unlike the classes converted alongside it on this sweep, both routes are published here, so the class and the constant must name the same refusal; a test pins that they do.

.github/workflows/cut-rc.yml

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -673,14 +673,38 @@ jobs:
673673
echo "template surfaces declared by stampedPaths() (${#TEMPLATE_SURFACES[@]}):"
674674
sed 's/^/ /' "$TEMPLATE_LIST"
675675
676+
# The release-index surface `sync-release-index-currency.mjs` stamps, read
677+
# from `syncedPaths()` — which derives it from `INDEX_PATH` in the gate
678+
# whose finding that rewriter clears. Same terms as the two lists above, and
679+
# for the same reason: a third literal here would be a third contract. On an
680+
# RC cut this rewriter writes NOTHING (an `-rc` heading is not a GA version,
681+
# so the newest GA of the major does not move), so this list normally stages
682+
# nothing — it is declared so that the one cut where the index IS stale gets
683+
# a complete commit instead of the refusal below.
684+
INDEX_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-release-index-surfaces.txt"
685+
if ! node --input-type=module \
686+
-e 'import { syncedPaths } from "./scripts/sync-release-index-currency.mjs"; for (const p of syncedPaths()) console.log(p);' \
687+
> "$INDEX_LIST"; then
688+
echo "::error::could not resolve syncedPaths() from scripts/sync-release-index-currency.mjs, so the release-index half of the release file surface is unknown. Refusing to push."
689+
exit 1
690+
fi
691+
if [ ! -s "$INDEX_LIST" ]; then
692+
echo "::error::syncedPaths() in scripts/sync-release-index-currency.mjs resolved EMPTY, so the release index would go unstaged even though the version pass can rewrite it. Refusing to push."
693+
exit 1
694+
fi
695+
mapfile -t INDEX_SURFACES < "$INDEX_LIST"
696+
echo "release-index surfaces declared by syncedPaths() (${#INDEX_SURFACES[@]}):"
697+
sed 's/^/ /' "$INDEX_LIST"
698+
676699
git add -A -- \
677700
'*package.json' \
678701
'*CHANGELOG.md' \
679702
.changeset \
680703
.objectui-sha \
681704
packages/spec/src/kernel/protocol-version.ts \
682705
"${TEMPLATE_SURFACES[@]}" \
683-
"${DOCS_SURFACES[@]}"
706+
"${DOCS_SURFACES[@]}" \
707+
"${INDEX_SURFACES[@]}"
684708
685709
STAGED="$(git diff --cached --name-only)"
686710
if [ -z "$STAGED" ]; then
@@ -689,16 +713,17 @@ jobs:
689713
fi
690714
691715
# Re-check every staged path against the allowlist. The pathspec above is
692-
# convenience; THIS is the guarantee. Three filters, same allowlist the
716+
# convenience; THIS is the guarantee. Four filters, same allowlist the
693717
# pathspec used: the fixed release paths by pattern, then the declared
694-
# template and doc surfaces by WHOLE-LINE EXACT match (`-xF`) against the
695-
# very lists that were staged — so neither derived filter can accept a path
696-
# its declaration does not name, and neither needs regex-escaping of the
697-
# paths to stay exact.
718+
# template, doc and release-index surfaces by WHOLE-LINE EXACT match
719+
# (`-xF`) against the very lists that were staged — so no derived filter
720+
# can accept a path its declaration does not name, and none needs
721+
# regex-escaping of the paths to stay exact.
698722
BAD="$(printf '%s\n' "$STAGED" \
699723
| grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^\.objectui-sha$|^packages/spec/src/kernel/protocol-version\.ts$' \
700724
| grep -vxF -f "$TEMPLATE_LIST" \
701-
| grep -vxF -f "$SURFACE_LIST" || true)"
725+
| grep -vxF -f "$SURFACE_LIST" \
726+
| grep -vxF -f "$INDEX_LIST" || true)"
702727
if [ -n "$BAD" ]; then
703728
echo "::error::the version commit would carry paths outside the release file surface. Refusing to push. Offending paths follow; if the version pass legitimately grew a new output, widen the allowlist in this workflow deliberately."
704729
printf '%s\n' "$BAD" | sed 's/^/::error:: unexpected: /'

.github/workflows/lint.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,50 @@ jobs:
20332033
- name: Template version-time rewriter self-test
20342034
run: pnpm check:template-version-sync
20352035

2036+
# #15332 — the THIRD version-time rewriter, and the third self-test beside
2037+
# the two above. scripts/sync-release-index-currency.mjs joins the root
2038+
# `version` chain and stamps the release index's "current series: X.Y.Z,
2039+
# released YYYY-MM-DD" status field from packages/spec/CHANGELOG.md, so the
2040+
# sentence cannot go stale the way it did on three consecutive minors
2041+
# (#10232, #11649, #15332). The gate that names the staleness,
2042+
# check-release-section-coverage, structurally could not fire on the change
2043+
# that causes it: the version commit is opened by changesets/action with the
2044+
# default GITHUB_TOKEN and gets NO CI, and the step further down in this file
2045+
# runs that gate WITHOUT --strict, where a finding is advisory and the job is
2046+
# green by design.
2047+
#
2048+
# ⚠️ This step is NOT that gate's --strict arm, and must never become it.
2049+
# Promoting --strict here would also red a release page that has no section
2050+
# for a just-published train, i.e. every PR between a version commit and its
2051+
# release-notes PR — a policy question, deliberately not decided by adding a
2052+
# rewriter. What runs here is ONLY --self-test, whose cases are string
2053+
# fixtures: it never reads the live index for currency and never reads a
2054+
# release page at all, so no corpus state can red it. The advisory,
2055+
# non-strict `Release section-coverage guard` step below is untouched.
2056+
#
2057+
# Why it must run at PR time and not only in release.yml's post-version lane,
2058+
# where the rewriter's own corpus is — two gates in this repo say so, and both
2059+
# name a PR-TIME caller:
2060+
# * check-self-test-wired — a script CI runs that ships a --self-test must
2061+
# have that self-test run by CI.
2062+
# * scripts/pm/dispatch-gates.mjs --self-test — "a deferred pair defers the
2063+
# LEAD, not the load break": a family no every-PR workflow runs leaves its
2064+
# import edges unwatched at PR time, so a change breaking the MODULE LOAD
2065+
# of check-release-section-coverage.mjs (which this rewriter imports its
2066+
# surface, scope and verdict from) would not redden the PR that made it.
2067+
# Measured: with release.yml as the only caller, that self-test is
2068+
# 2-of-1382 RED naming this family; with this step, 1382/1382.
2069+
#
2070+
# Only the --self-test runs here, for the same reason the two steps above
2071+
# give: on a corpus that is already current the rewriter has nothing to do, so
2072+
# CI can never observe it working. The self-test is where a STALE entry is
2073+
# observed being stamped through the gate's own verdict, and — the control
2074+
# that matters just as much — where a CURRENT index is observed left
2075+
# byte-identical and UNWRITTEN, because this rewriter writes into curated,
2076+
# reader-facing prose.
2077+
- name: Release-index currency version-time rewriter self-test
2078+
run: pnpm check:release-index-currency-sync
2079+
20362080
# #4851: the docs-accuracy-audit workflow's default scope is a generated list
20372081
# (a workflow script runs in a vm with no filesystem, so it cannot enumerate
20382082
# content/docs/ itself — the caller hands the list in). Hand-kept, that list

.github/workflows/release.yml

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,12 @@ jobs:
387387
# nothing to do cannot be observed working.
388388
# check:template-version-sync the template stamper's, for the same
389389
# reason.
390+
# check:release-index-currency-sync
391+
# the release-index stamper's (#15332), for the
392+
# same reason again — on a train whose index is
393+
# already current it rewrites nothing, and a
394+
# rewriter with nothing to do cannot be observed
395+
# working.
390396
# check:nul-bytes lint.yml's one UNCONDITIONAL gate whose
391397
# corpus is every byte in the tree. The 76
392398
# generated CHANGELOGs are the only prose
@@ -539,13 +545,30 @@ jobs:
539545
exit 1
540546
fi
541547
542-
# Same three filters cut-rc.yml applies to the same surface: the fixed
543-
# release paths by pattern, then the two declared lists by WHOLE-LINE
544-
# EXACT match, so neither derived filter can accept a path its
545-
# declaration does not name and neither needs regex-escaping.
548+
# The release-index surface the currency stamper writes, on the same terms
549+
# again: resolved from `syncedPaths()` in the rewriter, which derives it
550+
# from `INDEX_PATH` in the gate whose finding it clears. Third rewriter,
551+
# third resolved list, zero restated literals.
552+
INDEX_LIST="${RUNNER_TEMP}/post-version-release-index-surfaces.txt"
553+
if ! node --input-type=module \
554+
-e 'import { syncedPaths } from "./scripts/sync-release-index-currency.mjs"; for (const p of syncedPaths()) console.log(p);' \
555+
> "${INDEX_LIST}"; then
556+
echo "::error::could not resolve syncedPaths() from scripts/sync-release-index-currency.mjs, so the release-index half of the post-version surface is unknown. Refusing to call this tree validated."
557+
exit 1
558+
fi
559+
if [ ! -s "${INDEX_LIST}" ]; then
560+
echo "::error::syncedPaths() in scripts/sync-release-index-currency.mjs resolved EMPTY, so the release index the version pass rewrites would read as an unexpected path. Refusing to call this tree validated."
561+
exit 1
562+
fi
563+
564+
# Same four filters cut-rc.yml applies to the same surface: the fixed
565+
# release paths by pattern, then the three declared lists by WHOLE-LINE
566+
# EXACT match, so no derived filter can accept a path its declaration
567+
# does not name and none needs regex-escaping.
546568
UNEXPECTED="$(grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^packages/spec/src/kernel/protocol-version\.ts$' "${MOVED_FILE}" \
547569
| grep -vxF -f "${TEMPLATE_LIST}" \
548-
| grep -vxF -f "${SURFACE_LIST}" || true)"
570+
| grep -vxF -f "${SURFACE_LIST}" \
571+
| grep -vxF -f "${INDEX_LIST}" || true)"
549572
if [ -n "${UNEXPECTED}" ]; then
550573
printf '%s\n' "${UNEXPECTED}" | sed 's/^/::error:: unexpected: /'
551574
echo "::error::the version pass wrote outside the reviewed post-version surface (paths above). This is the treadmill guard: a new version-time output must arrive together with the gate that judges it. Add the surface to the declaration its rewriter reads, and its gate to the content half below — deliberately, in one reviewed diff."
@@ -591,6 +614,7 @@ jobs:
591614
run_gate pnpm check:docs-image-tag
592615
run_gate pnpm check:docs-image-tag-sync
593616
run_gate pnpm check:template-version-sync
617+
run_gate pnpm check:release-index-currency-sync
594618
run_gate pnpm check:nul-bytes
595619
run_gate pnpm check:release-notes
596620
run_gate pnpm check:release-page-status

content/docs/api/error-catalog.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ reads those as field filters, so one naming no field could only match zero
7575
records and is rejected rather than answered with an empty page — plus every
7676
other read axis that names a field: `select`, `expand` (a real field that holds
7777
no reference gets its own message), `searchFields` (a real field outside the
78-
searchable set gets its own message), `groupBy`, and `aggregations[].field`.
78+
searchable set gets its own message, and so does a value with the wrong shape —
79+
an array entry that is not a string, or a value that is itself neither a
80+
comma-separated string nor an array of field names), `groupBy`, and
81+
`aggregations[].field`.
7982
Off the request path the same code answers `backfillSummaryNulls`'s
8083
`recomputeUndefinedOnEmpty` (`os migrate summary-nulls
8184
--recompute-undefined-on-empty object.field`) when an entry is not a roll-up

content/docs/releases/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ migration steps, then covers new capabilities and notable fixes.
1818

1919
## Versions
2020

21-
- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.2.0, released 2026-08-23).
21+
- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.3.0, released 2026-09-04).
2222
- [v16.0.0](/docs/releases/v16) — One org identifier (`organizationId`) across hooks and actions, quorum + per-group sign-off (会签) approvals with metadata-declared decision actions, time-relative automations, filtered roll-ups, strict dashboard widgets, an identity-scoped MCP stdio transport, and a platform-wide enforce-or-remove sweep that makes dead metadata loud; 16.1 adds a `requires` capability-provider preflight, two more dashboard build gates, and `runAs:'user'` automations that run with the triggering user's real grants (final release: 16.1.0).
2323
- [v15.0.0](/docs/releases/v15) — Explain record access layer by layer, a docked AI workspace in the Console, project-ready Gantt charts, and phone sign-in; 15.1 adds permission-following attachments, no-code third-party connectors, dashboard-wide filters, pinyin search, and whole-record inline editing — with materially safer multi-tenant and write-path defaults (final release: 15.1.1).
2424
- [v14.0.0](/docs/releases/v14) — ADR-0090 vocabulary convergence completed, object `enable.*` flags become real gates, admin user management, phone/SMS auth, book-audience enforcement, data-lifecycle contract, and effective-dated grants (final release: 14.8.0).

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"setup": "pnpm install && pnpm --filter @objectstack/spec build",
1818
"prepare": "node scripts/setup-git-hooks.mjs",
1919
"check:merge-driver": "node scripts/git-merge-regen.mjs --self-test && node scripts/check-regen-pending.mjs --self-test",
20-
"version": "changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs && node scripts/sync-docs-image-tags.mjs",
20+
"version": "changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs && node scripts/sync-docs-image-tags.mjs && node scripts/sync-release-index-currency.mjs",
2121
"release": "pnpm run build && bash scripts/build-console.sh && bash scripts/release-publish.sh",
2222
"docs:dev": "pnpm --filter @objectstack/docs dev",
2323
"docs:build": "pnpm --filter @objectstack/docs build",
@@ -128,6 +128,7 @@
128128
"check:release-notes": "node scripts/check-release-notes.mjs",
129129
"check:release-page-status": "node scripts/check-release-page-status.mjs --self-test && node scripts/check-release-page-status.mjs",
130130
"check:release-body": "node scripts/release-github-releases.mjs --self-test",
131+
"check:release-index-currency-sync": "node scripts/sync-release-index-currency.mjs --self-test",
131132
"check:node-version": "node scripts/check-node-version.mjs",
132133
"check:pnpm-acquisition": "node scripts/check-pnpm-acquisition.mjs --self-test && node scripts/check-pnpm-acquisition.mjs",
133134
"check:workflow-status-functions": "node scripts/check-workflow-status-functions.mjs --self-test && node scripts/check-workflow-status-functions.mjs",

0 commit comments

Comments
 (0)