Skip to content

feat!: retire the saved-report stack — /api/v1/reports, client.reports, IReportService, the reports capability, sys_saved_report / sys_report_schedule, @objectstack/plugin-reports - #20125

Merged
os-zhuang merged 15 commits into
mainfrom
claude/issue-20102-retire-saved-report-stack
Sep 27, 2026
Merged

os-zhuang merged 15 commits into
mainfrom
claude/issue-20102-retire-saved-report-stack

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20102
Clause-②: yes

What this does

Retires the saved-report stack whole, per the maintainer ruling on the card (「A. 退役」, then 「你直接派发处理这个退役任务。」): the reports platform capability, the saved-report service contract in @objectstack/spec/contracts, the sys_saved_report / sys_report_schedule platform objects, the eight /api/v1/reports routes, the SDK's client.reports namespace and the @objectstack/plugin-reports package. No deprecation window.

Not touched: the report metadata kind (ReportSchema, defineReport, /meta/report, datasets, analytics). The two only shared a word.

  • requires: ['reports'] is refused loudly by defineStack (STACK_CAPABILITY_UNKNOWN, 422) with a prescription, not the typo advice: "requires: 'reports' was removed in @objectstack/spec 17.5.0 — the capability mounted the saved-report stack (…), which had no consumer and was retired whole. Delete the token. A report is report metadata (ReportSchema …); a saved ad-hoc object query is a ListView on that object." The text lives in the new RETIRED_PLATFORM_CAPABILITY_GUIDANCE (@objectstack/spec/kernel), which is kept disjoint from PLATFORM_CAPABILITY_TOKENS. The token meets the same text at all three doors that read requires: defineStack refuses it (above); os serve warns with it when it loads an older artifact that still declares the token; and os validate / os build, which parse a plain-object config (export default { … }, no defineStack call, so no parse-time vocabulary check), report it as a non-fatal capability advisory whose message is that prescription, never "check for a typo". The last door reads the map in renderCapabilityMessage (packages/cli/src/utils/capability-preflight.ts) and keeps its existing warn-first posture for unknown tokens.
  • ADR-0087: a D3 semantic entry, packages/spec/src/migrations/entries/semantic/18.saved-report-stack-retired.ts (surface, replacement, reason, acceptance criteria). The registry region was regenerated. The changeset .changeset/20102-retire-saved-report-stack.md is **BREAKING** with a FROM → TO line for each surface, marked registered saved-report-stack-retired (check-adr-0087-registration: [BREAKING+bang+clause-②-narrowing]).
  • Every /api/v1/reports path gives the platform's standard answer for an unmatched route: 404, byte-identical to a path that was never mounted. It is not a 405, because no verb survives at those paths. Pinned by packages/rest/src/reports-routes-retired.test.ts, which uses the real HonoHttpServer with its notFound seam.

Per-site disposition (every site the card lists)

card site disposition
packages/plugins/plugin-reports/** deleted whole: package, tests, workspace entry. pnpm-lock.yaml regenerated with pnpm install. Removed from .changeset/config.json (fixed group), scripts/check-test-source-alias.mjs (alias ledger row), scripts/engine-double-contract.baseline.json (4 rows), scripts/doc-authoring-prose-id.baseline.json and scripts/test-shard-timings.json
platform-objects/src/audit/sys-saved-report.object.ts, sys-report-schedule.object.ts deleted, with their audit/index.ts exports and i18n-extract.config.ts rows. The four *.objects.generated.ts bundles were regenerated by node scripts/check-i18n-bundles.mjs --write (not edited by hand). The object-list pin bundle-ownership.test.ts and PLATFORM_OBJECTS_BY_PACKAGE in the spec were updated. platform-iana-timezone-columns.test.ts now pins the surviving sys_job.timezone column on its own. scripts/platform-object-tenancy-census.json was regenerated (84 → 82 registered)
packages/rest/src/rest-server.ts — the /api/v1/reports family registerReportsEndpoints deleted (340 lines) along with its provider field. The constructor slot is kept as a retired placeholder typed undefined (_retiredReportsServiceProvider?: undefined). Every later parameter is positional (the #15256 hazard), so deleting the slot would silently re-bind each argument after it at all 276 new RestServer( sites. With the placeholder, passing a provider there is a compile error. That error found four test callers that still passed one (execctx-consumer-census, ui-view-environment-ownership, ui-view-route-identity.measurement, ui-view-route-tenancy.measurement); each now passes undefined
packages/rest/src/rest-route-ledger.ts 8 rows removed. The tripwire table in rest-write-response-internal-fields.tripwire.test.ts lost its 5 matching rows. The two /reports enumeration-oracle tests were deleted with the routes they tested. rest.test.ts lost its reports endpoints block
packages/client/src/index.ts — reports namespace removed, along with the four contract type imports. client.test.ts now pins the absence twice: a @ts-expect-error on client.reports, which tsconfig.test.json compiles, and a runtime 'reports' in client === false. return-type-precision.test.ts lost its two report rows
packages/spec/src/contracts/report-service.ts deleted. IReportService, SavedReport, ReportSchedule, ReportQuery, ReportFormat, ReportRunResult, SaveReportInput and ScheduleReportInput leave api-surface/ and export-origins/, both regenerated with gen:api-surface / gen:export-origins
packages/spec/src/kernel/platform-capabilities.ts — reports the token is removed from PLATFORM_CAPABILITY_TOKENS and its row from PLATFORM_CAPABILITY_PROVIDERS. RETIRED_PLATFORM_CAPABILITY_GUIDANCE is added; stack.zod.ts#validateKnownCapabilities reads it
packages/spec/src/migrations/ the ADR-0087 entry above; gen:migration-registry rewrote registry.ts
packages/cli/src/commands/serve.ts, packages/cli/package.json the reports row is removed from CAPABILITY_PROVIDERS and 'reports' from NEEDS_JOB_OR_QUEUE; the dependency is dropped; the unknown-token warn now uses the retirement prescription. serve-capability-identity.test.ts lost its row
packages/cli/src/utils/capability-preflight.ts (the os validate / os build door) renderCapabilityMessage's unknown branch returns RETIRED_PLATFORM_CAPABILITY_GUIDANCE[token] for a retired token (own-property read) and the typo hint otherwise. Pinned by a unit case in capability-preflight.test.ts (queue tier) and by requires-retired-capability.e2e.test.ts, which runs os validate --json and os build --json over a plain-object config declaring reports and a misspelled control token (nightly tier by its .e2e name, integration project)
skills/objectstack-platform/SKILL.md (Tier H) the requires: table that mirrors CAPABILITY_PROVIDERS no longer lists reports. Its lead-in said "all 20 of its entries"; the map now has 20 entries and the table lists the 19 opt-in ones (package-registry is on the always-on slate and was already absent from the table), so the line now reads "its 19 opt-in entries"
packages/core/src/fallbacks/index.ts, packages/lint/src/validate-sortable-fields.ts these comments treated the retired objects as live; they now cite them as history
Docs content/docs/api/client-sdk.mdx (namespace row), plugins/packages.mdx (the section, and the plugin count 18 → 17), permissions/system-context.mdx (rows 44, 45 and 66 removed, the table renumbered, and the counts regenerated with pnpm gen:system-context-census), protocol/objectql/query-syntax.mdx, ui/apps.mdx (the nav item's text said "saved report"; it points at report metadata), README.md (package row). content/docs/releases/ is not edited
docs/qa/platform-checklist/areas/dashboards.json the two items that drove the saved-report API, dashboards.saved-report-ownership and dashboards.report-schedule-dispatch-delivery, are retired, not deleted, as the checklist README's lifecycle requires: status: "retired", retiredReason, revision bumped and a history row added. Their symbol-anchored sources now point at the retirement (the pin, the D3 entry, RETIRED_PLATFORM_CAPABILITY_GUIDANCE, validateKnownCapabilities), which keeps the file's symbol-anchor floor (22) intact without lowering it. Both ids were unmapped from coverage.json → report

Found beyond the card's list (with the reason)

  • Error-code ledger: nine rows (REPORTS_LIST_FAILED, REPORT_DELETE_FAILED, REPORT_GET_FAILED, REPORT_NOT_FOUND, REPORT_RUN_FAILED, REPORT_SAVE_FAILED, REPORT_SCHEDULE_FAILED, SCHEDULES_LIST_FAILED, SCHEDULE_DELETE_FAILED) came out with their only emitter, under the ledger's own "Retiring a code" rule. There is no producer left in packages/**, and objectui and cloud have no reader (grep over both origin/main). content/docs/references/api/{contract,error-code-ledger}.mdx were regenerated with gen:docs.
  • @objectstack/metadata-protocol: the INVALID_SORT hint for { field, direction } named the retired contract as the source of direction. It now names the better-auth adapter's sortBy, which still uses that word. Code and status are unchanged. Its source comment and the matching comments in spec/data/query.zod.ts, objectql and driver-memory tests and eslint.config.mjs were updated in the same way.
  • Present-tense comments that became false: objectql/engine.ts, objectql/filter-comparand-shape.ts, metadata-protocol/protocol.ts and core/security/operation-private-keys.ts (plus its pin, which asserted that the scan reaches the deleted file). spec/system/job.zod.ts, spec/data/object.zod.ts, platform-objects sys-job / sys-business-unit.
  • Capability docs: content/docs/capabilities/analytics.mdx and capabilities/index.mdx advertised "scheduled email digests" that deliver a report. The saved-report stack was the only thing that delivered one, so the claim is removed (Prime Directive chore: version packages #10).
  • Ratchets and censuses that measure the tree:
    • scripts/check-route-envelope.mjs: rest-server stringError 44 → 43 and siblingCode 69 → 58, banked as the ratchet requires.
    • execctx-consumer-census.test.ts: 77 → 69 sites and 100 → 92 mentions. All eight were bare sites; 24 caught / 45 bare, and the caught half is asserted unchanged.
    • tenant-audit-census: 227 → 218 write sites, regenerated with node scripts/tenant-audit-census.mjs --write, plus six hand-written prose figures on the page.
    • check-system-context-census: the why: row reference 61 → 59 and its self-test expectations.
    • check:lockstep-package-count --fix: 70 → 69.
    • packages/spec/llms.txt: 69 → 68 packages.

Physical tables — what an existing deployment's sys_saved_report / sys_report_schedule tables do

They are left in place, untouched. There is no backfill, no reaper and no drop. That follows the repo's measured convention for a retired platform object, cited rather than invented:

  • packages/cli/src/utils/unmanaged-tables.ts (header): "Retiring an object therefore strands its table forever", and "⛔ It never drops anything, and never proposes a drop … Dropping an existing physical table is destructive and hard to reverse: that decision stays with a human". os migrate plan lists such a table in its informational unmanaged-tables section.
  • content/docs/deployment/cli.mdx: os migrate "never drops a table that is absent from your metadata".
  • The precedent for a retired sys_ object: packages/spec/src/migrations/entries/semantic/18.scim-provider-object-retired.ts ("Existing sys_scim_provider tables in deployed databases are left in place untouched … no backfill, no reaper, no migrate command"), whose measured stranded table is the fixture of unmanaged-tables.test.ts.

The new D3 entry's acceptanceCriteria and the changeset state the same thing.

Evidence surface (re-verified, not taken from the card)

Patterns: plugin-reports, sys_saved_report, sys_report_schedule, IReportService, /api/v1/reports, client.reports, .reports.(list|save|get|delete|run|schedule|listSchedules|unschedule), the seven contract type names, the nine error codes, ReportsServicePlugin, service.reports, requires with reports.

  • objectstack at base d624002: no caller outside the stack's own files and tests. The only requires hits in examples/** do not include reports.

  • objectui origin/main 2a943bf, and the pinned .objectui-sha f8a9d0f: one hit, content/docs/core/report-schema.mdx:219. It is a local interface ReportSchedule in objectui's own report-metadata doc, not an import of the spec contract, and it has no runtime reader. The pinned checkout imports nothing this PR removes, so the Console Pin Gate is unaffected.

  • cloud origin/main 48d7066: two test-only readers, recorded here for the PM:

    • packages/objectos-runtime/src/capability-coverage.test.ts:45 has the reports row the card already names.
    • packages/objectos-runtime/src/capability-loader.test.ts:475-497 drives requires: ['reports'] and asserts pkg: '@objectstack/plugin-reports', read from the spec's provider map (line 492).

    Both go red when cloud installs a spec release without the token. Neither is a runtime consumer, and no app in cloud declares the token.

Remaining hits of the acceptance grep on the PR head, with reasons

git grep -n "plugin-reports\|sys_saved_report\|sys_report_schedule\|IReportService\|/api/v1/reports" still hits the following; every hit is one of these kinds:

  1. Release-owned history: every CHANGELOG.md, content/docs/releases/** (⛔ not editable in a code PR), and past .changeset/*.md entries that describe earlier shipped changes.
  2. ADR-0087 history: earlier semantic entries (17.engine-find-formula-*, 17.sharing-execution-context-retired, 17.sort-node-direction-rejected, 18.engine-dotted-filter-refused, 18.filter-text-operator-declared-type-refused, 18.platform-timezone-columns-iana-domain-refused), their generated copies in migrations/registry.ts, spec-changes.json and docs/protocol-upgrade-guide.md, and this PR's own 18.saved-report-stack-retired.
  3. Governed surfaces. ⚠️ This PR EDITS two Tier H paths. The first is skills/objectstack-platform/SKILL.md: the capability table's reports row entry is removed (see the per-site table), so that file is no longer a hit. The second is docs/adr/0096-execution-surface-identity-admission.md:194. Its #isSystem-anchored citation of the deleted packages/plugins/plugin-reports/src/report-service.ts failed check-adr-symbol-anchors (CI round 1, Lint step 🔗 Broken links detected in documentation #104). Following ADR-0015's precedent, the edit keeps the path as a historical, deliberately unlinked citation and names retire the saved-report stack — sys_saved_report / sys_report_schedule, /api/v1/reports, client.reports, IReportService, the reports capability and @objectstack/plugin-reports (zero consumers; not the report metadata kind) #20102; no anchor-exempt marker is used. The line cannot land separately, because deleting the file turns that gate red. check-governed-merges --branch HEAD at e4ead74 reads GOVERNED (2 of 114 paths: docs/adr/** ×1, skills/** ×1), landing tier H (人合), on top of the size-driven human merge. Not edited here: the other historical records docs/adr/** (0021, 0029, 0053, 0073, Tier H), and .claude/skills/pm-dispatch/SKILL.md:164, .claude/skills/pm-dispatch/references/lanes/services.md:8 and .claude/workflows/docs-accuracy-audit.js:220, which list the package in lane rosters (Tier S). They stay untouched and are listed below as follow-ups.
  4. Dated audit records: docs/audits/2026-09-test-log-volume-census.md:309 and docs/qa/platform-checklist/FOLLOW-UPS.md:22 (a row marked as fixed).
  5. The retirement itself: the new tests (reports-routes-retired.test.ts, reports-capability-retirement.test.ts, client.test.ts), the changeset, the two retired checklist items (their historical steps are kept, as the checklist lifecycle requires), and comments that mark a removal (platform-objects/src/audit/index.ts:16, bundle-ownership.test.ts:36).
  6. Historical comments that remain true: "the way the plugin-reports / service-messaging members did" in three connectors, plugin-approvals and service-knowledge; plugin-audit/comment-access-hooks.ts:214; service-storage/attachment-access-hooks.ts:118; the timezone-precedent notes in sys-job, sys-organization, sys-business-unit and org-hierarchy-timezone.test.ts; the sharing-service.test.ts:529 history docblock; scripts/isystem-census.mjs:20; scripts/optional-error-sink-contract.baseline.json:22; and scripts/check-adr-0087-registration.mjs:599.
  7. Unrelated: packages/spec/src/system/environment-artifact.test.ts uses a fictional third-party name, @acme/plugin-reports. scripts/check-doc-route-spelling.mjs:748-901 has self-test fixture literals that bring their own ledger.

Verification

All readings below are from this branch. The derived gate union was run at head ecb3bb5 (git rev-parse --short HEAD), after the last commit.

  • Builds: turbo run build --filter=@objectstack/cli... --filter=@objectstack/client... --filter=@objectstack/lint... gave 56/56 tasks; the connectors, plugins, apps and client-react closure gave 57/57 (both under os-verify-lock, VERDICT command-exit 0). pnpm --filter @objectstack/spec build also passed.

  • Tests:

    package files / tests
    @objectstack/spec (full suite) 570 files, 16371 passed
    @objectstack/rest (full suite) 194 files. The first run failed 5: the census counts and the sibling-control status. Both were fixed, and the touched files re-run: 5 files, 88 passed, and 10 passed in the pin
    @objectstack/platform-objects 55 files, 911 passed
    @objectstack/client 50 files, 635 passed
    @objectstack/cli --project unit 224 files, 3155 passed

    Also run: core operation-private-keys.pin (5 passed) and metadata-protocol protocol.orderby-vocabulary (11 passed).

  • Typecheck: spec, platform-objects, rest, client, cli, core, metadata-protocol, objectql, lint and driver-memory all exit 0. The first rest run was red at exactly the four test callers that still passed a provider into the retired slot (TS2345/TS2322, "not assignable to parameter of type 'undefined'"), which is the placeholder doing its job; after the fix it is green. client typecheck green means the @ts-expect-error on client.reports is live, not a TS2578.

  • Ablation, one-off and not kept: node scripts/ablation-replace.mjs replaced the retirement branch in stack.zod.ts#validateKnownCapabilities with if (false as boolean). The anchor went 1 → 0 on disk (blob 3c09282f → 8bb2143f), and reports-capability-retirement.test.ts went red, 2 failed / 5 passed (the envelope-with-prescription case and the dedup case). The tool restored the file: blob equal to HEAD, and git diff HEAD empty.

  • Gates: node scripts/pm/dispatch-gates.mjs --commands derived 164 commands from the merge base; --ran reconciles 164 derived / 164 run / 0 unrun. Results:

    • Green: 161, including check:generated (api-surface, export-origins and docs regenerated), check:adr-0087-registration --base d624002, check:platform-checklist, check:system-context-census, check:tenant-audit-census, check:i18n, check:i18n-coverage, check:route-envelope, check:skill-examples, check:dual-build-cjs-loads, check:api-surface, check:authorable-surface, check:liveness, check:variant-docs, check:nul-bytes and check:engine-double-contract.
    • Refused on the shallow clone (NOT MEASURED, and not a finding): check-engine-split-ratio --days 90 and check-plugin-teardown-shape --self-test. Each names its own deepen remedy.
    • Timed out (NOT MEASURED): check:pm-dispatch-gates, at the 480 s and 580 s budgets under box contention, with no failed assertion in its output.
    • One ordering race in my parallel run: check:query-options-erasure read a scratch .examples-build file that check:skill-examples was deleting at the same moment. Re-run alone, it exits 0.

Contract review round 1 fixes (head e4ead74)

All readings are from e4ead74 (git rev-parse --short HEAD), after the last commit, on top of a merge of origin/main f09d412.

  • Reproduced before the fix (at 4ee2885): over a plain-object config with requires: ['reports'], both os validate --json and os build --json exited 0 with the warning record {"token":"reports","message":"requires: \"reports\" is not a known platform capability — check for a typo."}. After the fix, both carry {"token":"reports","message":"requires: 'reports' was removed in @objectstack/spec 17.5.0 — … Delete the token. …"}, still exit 0, and the text face prints the same line.
  • Tests: capability-preflight.test.ts + vitest-tiers-partition.test.ts: 2 files, 39 passed. requires-retired-capability.e2e.test.ts (run with OS_TEST_TIERS=nightly): 3 passed. The full @objectstack/cli --project unit: 224 files, 3157 passed. spec reports-capability-retirement.test.ts + platform-capabilities.test.ts: 39 passed. runtime external-validation-shutdown-clears-timers.test.ts: 6 passed. platform-objects object-field-editor-panel-echo-decisions.test.ts: 11 passed. Dogfood, all three shards: 46 files / 354 passed, 46 files / 311 passed, and 45 files / 438 passed (1 file and 3 tests skipped).
  • Typecheck: cli, spec, runtime and platform-objects all exit 0.
  • Ablation, one-off and not kept: node scripts/ablation-replace.mjs replaced the retired-token branch in renderCapabilityMessage with if (false) {. The anchor went 1 → 0 on disk (blob 355e5a2e → 3f19d585). The unit case went red (1 failed / 16 passed), and so did both door cases of the e2e (2 failed / 1 passed). The tool restored the file: blob equal to HEAD, and git diff HEAD empty.
  • Gates: dispatch-gates.mjs --commands over this round's nine paths derived 100 commands, and all 100 exit 0. Nine of them first refused with PREREQUISITE NOT MET (exit 3): the spec dist was stale after the docblock edit, and the CLI and eight other packages had no dist. Each was re-run after the build it named. check-skills-token-ratchet puts skills/objectstack-platform/SKILL.md at 5831 tokens against a ceiling of 5833; the first spelling of the count line was 5839 and was reworded. The branch-wide --ran reconciliation found 175 derived: the 100 above plus 75 more, and 73 of those 75 exit 0. They include check:generated, check:skill-examples, check:spec-changes, check:adr-anchors and check:platform-checklist. The other 2 are NOT MEASURED, and neither is a finding: check-engine-split-ratio --days 90 refuses on the shallow clone (exit 2), and check:pm-dispatch-gates timed out at 400 s (exit 124).
  • Lint (a narrowed run, not the repo sweep): eslint --no-inline-config --format json over the seven changed TS files: 7 files, 0 errors, 0 warnings. --print-config resolves a config for each of the seven. Type-aware linting is not enabled (eslint.config.mjs has no parserOptions.project), so this diff cannot change the verdict on any untouched file.
  • Skill readings (skills/**): skills/objectstack-platform/SKILL.md has 487 lines before and after, and 5833 → 5831 tokens. All published SKILL.md files together: 4402 → 4402 lines.
  • Comments corrected: the docblock at platform-capabilities.ts now cites reports-capability-retirement.test.ts for the disjointness pin. The runtime ExternalValidationPlugin docblock and its test header no longer say "one of only two" Plugin implementations that own setInterval. I counted over non-test sources at 4ee2885: 14 files call setInterval, and ExternalValidationPlugin is the only enclosing class that implements Plugin; the others are services, drivers, adapters and helpers. The echo-decisions rationale now names dataset.fields.measures.format as the live path and the two saved-report format fields as retired.

Acceptance notes

  • Size: 102 files, about 11.5k changed lines (+781 / −10,727, mostly the deleted package and generated bundles). That is over the 5,000 human-merge threshold, so this PR lands by a human merge (AGENTS.md §7 class c).
  • Public surface added: RETIRED_PLATFORM_CAPABILITY_GUIDANCE (one frozen const in @objectstack/spec/kernel), so that the defineStack refusal, the serve warning and the validate/build advisory share one text. It is the only new export.
  • RestServer retired slot: this is a deliberate placeholder, not a workaround for tolerance. It refuses a provider at compile time, and rest-api-plugin-slot-lookups.test.ts now lists index 10 as a non-provider and asserts that it is passed undefined.
  • Not measured locally: the CLI integration tier beyond the one new spawn file, requires-retired-capability.e2e.test.ts, which ran (the rest is declared to CI); the repo-wide pnpm lint (CI-owned); check:engine-split-ratio and check-plugin-teardown-shape --self-test, which refuse on this shallow clone and name their own remedy; and check:pm-dispatch-gates (a PM-script self-test battery that timed out at the 480–580 s budget under box contention, with no failing assertion in its output; scripts/pm/** is untouched).

Follow-ups (not in this PR)

  • cloud: the reports row in packages/objectos-runtime/src/capability-coverage.test.ts:45 (named by the card), and the requires: ['reports'] case at capability-loader.test.ts:475-497. Both need handling once a spec release without the token is installed there. This is the PM's, per the card.
  • npm: deprecating the published @objectstack/plugin-reports is a maintainer release act.
  • docs/NORTH-STAR.md:62 (Tier H): the row "reports · 报表自己建、自己存、只归自己,还能按时自己发出去" cites saved-report-ownership and report-schedule-dispatch-delivery, which this PR retires. Its "send on a schedule" leg no longer has a mechanism. That edit is the maintainer's.
  • .claude/** lane rosters (Tier S): the three roster lines in section 3 above still name plugin-reports.

维护者速读(草稿)

  • 改了什么: 把"保存的报表"整套机制删掉了。删除的是 sys_saved_report / sys_report_schedule 两张系统对象、/api/v1/reports 八条接口、SDK 的 client.reports、IReportService 契约、reports 能力令牌和 @objectstack/plugin-reports 包。对外发布的 platform 技能包里,能力表也同步删掉了 reports。报表元数据(report)、数据集和分析服务一概不动。
  • 为什么改: 它和报表元数据同名、却是另一套"存一段原始查询、定时发邮件"的东西。三个仓实测零调用方,容易让人和 AI 把两者混淆。按「A. 退役」的裁决立即退役,不留过渡期。
  • 风险与代价(含回滚):
    • 仍在 requires 里写 reports 的应用:用 defineStack 的会在构建时被明确拒绝;纯对象配置的应用跑 os validate / os build 时,会收到同一段退役提示(只警告,不阻断);os serve 加载旧产物时也会警告。三处都提示改用报表元数据或列表视图。
    • 已部署数据库里的两张表原样保留,不删不迁,os migrate plan 会把它们列为无人声明的表,由运维决定是否清理。
    • cloud 有两处测试行在升级 spec 时需要同步。
    • 平台目前没有"定时把报表发出去"的能力(North Star 第 62 行对应条目需维护者改)。
    • 回滚:revert 本 PR 即可,没有数据迁移需要反向执行。
  • 席位意见:
  • 你要做的: 本 PR 超过 5000 行,需人工合并;另需拍板 North Star 第 62 行的措辞。

Generated by Claude Code

@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:data protocol:system tests tooling labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 11 package(s): @objectstack/cli, @objectstack/client, @objectstack/core, @objectstack/lint, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/platform-objects, packages/plugins, @objectstack/rest, @objectstack/runtime, @objectstack/spec, touching 210 documentable anchor(s). ⚠️ 19 changed file(s) yielded no anchor (packages/cli/package.json, packages/core/src/fallbacks/index.ts, packages/lint/src/validate-sortable-fields.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

102 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 f09d4122bc8c2eb39414b83b1924217a04b66cb2.

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

What this run could not see
  • 19 changed file(s) yielded no anchor (packages/cli/package.json, packages/core/src/fallbacks/index.ts, packages/lint/src/validate-sortable-fields.ts, …) — pages documenting those are invisible to this run
  • 2 cross-cutting symbol(s) contributed no route anchor: tenantId (7 routes), isSystem (5 routes)
  • 4 anchor(s) matched too much of the corpus to be a work list: created_at (symbol, 34 pages), owner_id (symbol, 31 pages), owner_id (literal, 31 pages), sys_user (literal, 37 pages)
  • 37 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 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; 98 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 — 154 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 f09d4122bc8c2eb39414b83b1924217a04b66cb2 → packageMentionDocs.

Which tree this was computed on

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

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

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

…igures, census rows and baseline ceiling follow the retired family

Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg
Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 25, 2026
…on at the os validate / os build door

renderCapabilityMessage's unknown branch now consults
RETIRED_PLATFORM_CAPABILITY_GUIDANCE, so a plain-object config declaring
requires: ['reports'] gets the same prescription defineStack refuses it with
and os serve warns with, instead of "check for a typo". Posture unchanged:
an unknown token stays a non-fatal advisory at that door.

The published platform skill's CAPABILITY_PROVIDERS table drops the
retired token and states its real count (19 of 20; package-registry is
always-on). Three stale comments follow the retirement: the disjointness
pin's file, the setInterval-owning Plugin count (measured: one), and the
retired sys_saved_report / sys_report_schedule format paths.

Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg
Co-authored-by: Claude <noreply@anthropic.com>
@os-zhuang
os-zhuang marked this pull request as ready for review September 26, 2026 00:59
@os-zhuang
os-zhuang requested a review from hotlong as a code owner September 26, 2026 00:59
@os-zhuang
os-zhuang enabled auto-merge September 26, 2026 00:59
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 26, 2026
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 26, 2026
@os-zhuang
os-zhuang merged commit 8d1f7ab into main Sep 27, 2026
46 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-20102-retire-saved-report-stack branch September 27, 2026 02:02
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ved with PageSchema's declared default (objectstack-ai#20101) (objectstack-ai#20133)

Fixes objectstack-ai#20101

Clause-②: no

## What this changes

`PageSchema` declares `type: PageTypeSchema.default('record')`
(`packages/spec/src/ui/page.zod.ts`), so a page authored without `type`
is a record page. `saveMetaItem` parsed the body with that default, then
stored the authored body verbatim, and every `/meta` read serves a
stored row unparsed. A page saved without `type` was therefore stored
and served without it.

One helper, `withDeclaredPageTypeDefault` in
`packages/metadata-protocol/src/protocol.ts`, reads the default from the
registered `page` schema (`getMetadataTypeSchema('page').shape.type`,
the schema the save gate validates with). It fills the default only when
`type` is absent, so no `'record'` literal is added. It runs at two
seams:

- **Save.** It runs in `saveMetaItem` after the schema gate accepts the
body, beside the two existing grafts. New rows store the key, on draft
and publish saves alike.
- **Read.** It runs in `convertStoredItem`, the rehydration seam every
stored row passes through. Rows stored before this change are served
with the default. The row itself is never rewritten.

The read fill sits in `convertStoredItem` and not in
`convertStoredItemDetailed`. It is not an ADR-0087 conversion and emits
no notice, so `migrateStoredMetadata` (the Detailed caller) has nothing
to rewrite.

Scope is `PageSchema.type` only. The regions of `protocol.ts` that draft
PR objectstack-ai#20125 edits are not touched: this diff's hunks are at the
`resolveOverlaySchema` docblock, after `graftFoldedFormSections`, in
`convertStoredItem` and in the `saveMetaItem` schema-gate block.

## Why both seams (H2, measured)

- **Read alone** covers old rows, but it breaks objectstack-ai#4326's invariant that a
GET → PUT round-trip is byte-identical. The served body would carry a
key the row does not have. The first re-save of every such page would
then write a one-key change, a new checksum and a history row that
nobody authored. Ablation leg B below shows this: with the save fill
removed, the round-trip pin goes red.
- **Save alone** leaves every row stored before this change typeless on
every read. Covering those rows would need a stored-row rewrite, which
triage point 2 forbids. Ablation leg A below shows this.
- **Both together** is the smallest fix that is correct. Measured below:
a new row round-trips with the same bytes, checksum and version, and an
old row is served typed without being rewritten.

## Serve paths: base `226e00c038` vs head (H1)

The measurement used a typeless page, an explicit `type: 'app'` control,
a typeless draft, and a typeless row seeded directly into the store (the
"old row"). It ran at three levels: the real `RestServer` over the real
protocol, the protocol alone, and the real composition
(`bootStack(showcase)` plus `MetadataPlugin` over a build-shaped
artifact, over real HTTP).

| Path | base: typeless | base: `app` control | head: typeless | head:
`app` control |
|:---|:---|:---|:---|:---|
| `saveMetaItem` publish: stored body | absent | `app` | `record` |
`app` |
| `saveMetaItem` draft: stored draft body | absent | n/a | `record` |
n/a |
| `publishMetaItem` of that draft: stored and served | absent | n/a |
`record` | n/a |
| Registry write-through on save (unscoped kernel) | absent | `app` |
`record` | `app` |
| `GET /meta/page` (`getMetaItems`), cache on and off, new and old row |
absent | `app` | `record` | `app` |
| `GET /meta/page?preview=draft` | absent | n/a | `record` | n/a |
| `GET /meta/page/:name`, cached arm (`getMetaItemCached`) | absent,
ETag `7811092d` | `app`, ETag `6164defa` | `record`, ETag `69c54eb6` |
`app`, ETag `6164defa` (unchanged) |
| `GET /meta/page/:name`, uncached arm (`getMetaItem`) | absent | `app`
| `record` | `app` |
| `GET /meta/page/:name?state=draft` | absent | n/a | `record` | n/a |
| `getMetaItem` with `previewDrafts` | absent | n/a | `record` | n/a |
| `getMetaItemLayered`, old row (overlay / effective) | absent / absent
| n/a | `record` / `record` | n/a |
| `searchAll` page hit `pageType` | absent | `app` | `record` | `app` |
| `loadMetaFromDb` boot hydration into the registry | absent | `app` |
`record` | `app` |
| Old row at rest, after all the reads above | absent | n/a | absent
(not rewritten) | n/a |
| `migrateStoredMetadata` preview | scanned 4, canonical 4, pending 0 |
| scanned 4, canonical 4, pending 0 | |
| Real composition over HTTP: list, single, `?state=draft`, published
draft | absent | `app` (ETag `100766d6`) | `record` | `app` (ETag
`100766d6`, unchanged) |

## Consumers of the served page body (H3)

Every measured reading below used the explicit `app` page as its
control.

- **Server-side record-page selection by `type`: none in this repo.** A
grep of non-test `packages/**` for reads of a page's `type` finds two
readers. One is the `searchAll` page sweep (`pageType`, measured). The
other is the lint rule in `validate-page-visualization-bindings.ts`,
which asks `page.type !== 'list'`; its verdict cannot change, because
`record` is not `list`. Record-page selection lives in objectui's
`usePageAssignment`, per the card, and that is UNMEASURED here because
objectui is not reachable from this session.
- **Measured to carry the fill:** the list, single, cached, draft-state,
preview and layered reads; boot hydration; the registry write-through;
and the `searchAll` sweep. See the table above.
- **Carry the fill by code reading, not separately exercised:** these
read through `convertStoredItem` or `getMetaItems`. They are the runtime
authoring gate's stored universe (`foldStoredCollection`), the
`duplicatePackage` copy (which is then saved through `saveMetaItem`),
and the runtime package-export sweep (`getMetaItems`).
- **Deliberately raw:** `historyMetaItem` and `diffMetaItem` history
bodies, which are a record of what was written, and `getEffectiveLock`,
which reads protection only.
- **Outside this card's surface, not filled:** the `metadata` service's
`DatabaseLoader.rowToData` (`packages/metadata`). In the composition
measured (showcase plus `MetadataPlugin`), every read of the test pages
served the typed body.
- **Hashes.** The row checksum (the `sha256:` token `If-Match` compares)
is computed over the stored body, so a read cannot move it. The cached
read's ETag is a hash of the served content. It changes once for a
typeless page and is unchanged for the `app` control.
- **Round-trip.** Serving the new row, then PUTting it back, stores the
same bytes, checksum and version: the repository's identical-body
short-circuit fires and nothing is written. Serving the old row and
PUTting it back stores `type` once, and the served body is identical
before and after. That is the author's re-save, not a migration.

## Tests

All runs are at head `e3b83df825`. The probes ran at `c2a29b4573`, and
`protocol.ts` is byte-identical between the two commits.

- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2`: 188 files passed, 3 skipped; 2686 tests passed, 19
skipped; exit 0.
- `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc
--listFiles` includes all 191 test files, among them the three edited
here.
- REST `/meta` route tests: every `packages/rest/src/*meta*.test.ts`, 47
files, 667 tests passed, exit 0. They read the rebuilt
`metadata-protocol` `dist/`, which contains
`withDeclaredPageTypeDefault` (4 hits).
- Dogfood metadata round-trip tests: `dashboard-designer-roundtrip`,
`meta-published-and-state-routes`, `package-first-authoring`,
`meta-types-create-seed`, `showcase-object-extension-meta-read` and
`showcase-object-extension-scalar-divergence`. 6 files, 33 tests passed,
exit 0.

New pins:

- `protocol.stored-conversions.test.ts`: a stored typeless page row is
served with the declared default by the list and single reads, the draft
read and the list preview, and boot hydration. An explicit `app` row is
untouched. The row's bytes survive the reads, and
`migrateStoredMetadata` preview and apply report it canonical, with
`rewritten: 0`.
- `protocol.read-decorations.test.ts`: publish and draft saves store the
default. An explicit `app` is stored and served unchanged. GET → PUT
gives the same stored body, checksum and version.
- `protocol.search-published-pages.test.ts`: a typeless page's hit
carries `pageType` equal to the declared default.

Every pin reads the expected value from the schema. A precondition
asserts that value is `record`.

**Ablation.** Each leg ran through `scripts/ablation-replace.mjs`
(anchor hit 1 → 0, blob changed) with a trap restore, and restore was
proven each time (blob `1df09c2eb4` equals HEAD, `git diff HEAD` empty).
These suites import `./protocol.js` from source, so no rebuild was
involved. `ablation-dist-preflight --absent` confirms the markers never
reached `dist/`.

- Leg A, read fill removed: 4 failed of 48. These are the three
stored-conversions read pins and the search pin.
- Leg B, save fill removed: 2 failed of 48. These are the stored-body
pin and the GET → PUT byte-identity pin.
- Restored: 48 of 48 passed.

## Gates

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, derived at `e3b83df825`: 61 commands. All
61 were run with exit codes recorded, and `--ran` reports 61 run, 0
NOT-MEASURED (a derived zero).
- `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT
MET: 8 packages had no `dist/`). It passed after those were built.
- `node scripts/check-issue-citations.mjs --base 226e00c`: 5
citations, all resolve.
- Lint, narrowed: `eslint --no-inline-config --format json` over the 4
changed `.ts` files reports 4 files, 0 errors, 0 warnings.
`isPathIgnored` is false for them. `calculateConfigForFile` shows
neither `parserOptions.project` nor `projectService` set, so linting is
not type-aware and this diff cannot change the verdict on any file it
does not touch. The full `pnpm lint` is left to CI.

## Acceptance notes

- **Other declared defaults (triage point 3, census, not in this
diff).** Read from the built spec at `226e00c038`: 22 stored metadata
types were walked (types with `allowRuntimeCreate` or
`allowOrgOverride`). 15 of them declare 39 top-level defaults. The
positive control `page.type` is found, and the negative control
`page.object` (optional, no default) is not. `external_catalog` has no
registered schema. The save path stores all of these verbatim. Only
`page.type` was measured through the serve paths. The 38 besides
`page.type`:
  - `object`: `isSystem`, `datasource`
  - `hook`: `priority`, `async`, `onError`, `runAs`
  - `seed`: `externalId`, `mode`, `env`
  - `mapping`: `sourceFormat`, `mode`
  - `view`: `type` (`simple`)
  - `page`: `template`, `regions`, `isDefault`, `kind` (`full`)
  - `app`: `active`, `isDefault`
  - `action`: `type` (`script`), `refreshAfter`
  - `report`: `type` (`tabular`), `drilldown`
  - `flow`: `version`, `status`, `runAs`
  - `datasource`: `active`, `autoConnect`, `schemaMode`, `origin`
- `email_template`: `category`, `locale`, `variables`, `active`,
`isSystem`
  - `permission`: `isDefault`
  - `position`: `delegatable`
  - `skill`: `surface`, `active`
- The renderer half of triage point 4 ("is picked as the object's record
page") is objectui's `usePageAssignment`, and it is UNMEASURED from this
session. What this PR pins is the served body: `type` equal to the
declared default.
- The cached single read's ETag for a page stored without `type` changes
once, because the served content changed. The `app` control's ETag is
unchanged.
- The first re-save of an old typeless page stores `type` once, with one
history row. From then on its round-trip writes nothing.
- A stored row that spells the page type under a key `PageSchema`
refuses (for example the `pageType` alias key, which is reachable only
by a write that went around the save gate) is now read with `type:
'record'` beside that key. Such a row fails the schema either way and
carries `_diagnostics`. No such row was measured in any data.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rt, meta history and search (objectstack-ai#20061, objectstack-ai#20062) (objectstack-ai#20137)

Fixes objectstack-ai#20061
Fixes objectstack-ai#20062
Clause-②: no (narrowing)

## What

Four published REST doors read `?limit=` with a bare `Number()`. They
substituted, clamped or dropped the result and answered `200`:
- `GET /data/import/jobs`
- `GET /data/:object/export`
- `GET /meta/:type/:name/history`
- `GET /search`

Each door now reads `limit` against its own declaration and refuses a
value outside it. `GET /data/import/jobs` does the same for `offset`,
because its declaration bounds `offset` too. The refusal is `400` with
the data surface's existing `VALIDATION_FAILED` + `fields[]` envelope,
and the service is never called. Nothing a conforming caller sends
changes its answer.

The change is one private reader in `packages/rest/src/rest-server.ts`,
`readDeclaredQueryNumber`, with no new package export:
- **import jobs** read through `ListImportJobsRequestSchema.shape.limit`
/ `.offset`;
- **history** reads through `HistoryMetaItemRequestSchema.shape.limit`;
- **export** and **search** declare no request schema, so they read
through a module-private `z.number().int().optional()`: a whole number,
nothing about range.

A non-blank numeric string is coerced with `Number()` and handed to the
schema. Anything else (`abc`, a blank string, a structured value) goes
to the schema as it came, so the declaration refuses it by type.
`Number()` never gets to invent a `0` or a `NaN` for it.

## Before and after, measured on the real `RestServer` routes (spy
protocol)

Every row answered `200` before this PR.

| door | request | answered before | answers now |
|:--|:--|:--|:--|
| import jobs | `?limit=0` / `?limit=-3` | limit 50 / 1 | `400`,
`fields[0]` = `limit` / `min_value` |
| import jobs | `?limit=201` / `?limit=500` | limit 200 | `400`,
`max_value` |
| import jobs | `?limit=abc` / `1.5` / `Infinity` / blank | limit 50 /
1.5 / 200 / 50 | `400`, `invalid_type` |
| import jobs | `?offset=-1` / `abc` / `1.5` | offset 0 / 0 / 1.5 |
`400`, `min_value` / `invalid_type` |
| export | `?limit=abc` / empty / blank | `X-Export-Limit: 1` (a one-row
export) | `400`, `invalid_type` |
| export | `?limit=1.5` / `Infinity` | `X-Export-Limit: 1.5` / `50000` |
`400`, `invalid_type` |
| meta history | `?limit=abc` / `Infinity` | limit dropped: the whole
change log | `400`, `invalid_type` |
| meta history | `?limit=` (empty) / blank | limit 0: zero events |
`400`, `invalid_type` |
| search | `?limit=abc` | `limit: NaN` handed to `searchAll`: no overall
cap | `400`, `invalid_type` |
| search | `?limit=1.5` / `Infinity` / blank | 1.5 / clamped to 100 / 0
| `400`, `invalid_type` |

Unchanged and pinned by the lit controls:
- **Import jobs:** absent or empty `limit` → 50, absent or empty
`offset` → 0. Conforming values reach `findData` unchanged.
- **Export:** absent → 10000. `?limit=25` → 25. The door's own range
handling is untouched: `?limit=0` → 1 and `?limit=60000` → 50000.
- **History:** absent → no `limit` member. `5`, `0` and `1.5` are
forwarded unchanged, because the declaration is `z.number()` with no
`int()` and no bounds.
- **Search:** absent or empty → `undefined`. `20`, `0` and `500` reach
`searchAll` unchanged, and its `[1, 100]` clamp is its own business.

The refusal body carries:
- `code: 'VALIDATION_FAILED'`;
- `fields: [{ field: 'limit', code, message }]`;
- an `error` sentence naming the parameter;
- `object`, on the export door only. The handler throws
`validationFailure` (`@objectstack/types`), and each door's existing
catch (`handleRouteError` / `mapDataError`) writes the 400. So
`rest-server.ts` gains no response write site, and
`check:route-envelope`'s `siblingCode` count on it is unchanged (green).

## PM mechanism assumptions, measured

1. **The `parseIntegerParam` cycle: confirmed.**
`packages/runtime/package.json` depends on `@objectstack/rest`. rest's
dependencies are `core`, `metadata-core`, `observability`,
`platform-objects`, `service-package`, `spec`, `types` (plus `exceljs`,
`zod`). Nothing was moved; `packages/runtime/**` and `packages/spec/**`
are untouched.
2. **The declared-schema idiom holds, with one measured difference in
`fields[].code`.** zod 4.6.1 against the declared schemas, mapped
through `zodIssuesToFields`:
- `NaN`, a string, `Infinity`, and `1.5` against `int()` all give
`invalid_type`;
   - below `min` gives `min_value`; above `max` gives `max_value`.
These are all ADR-0114 catalog members, never zod's own codes. Runtime's
`parseIntegerParam` answers `invalid_number` for the same `abc` / `1.5`;
see the open question in the report.
3. **The doors match the card, with two corrections.**
- Export serves no declared request schema.
`CreateExportJobRequestSchema` is the contract of `POST
/api/v1/data/:object/export`, which rest does not mount.
`ExportRequestSchema` (`contract.zod.ts`) is bound to no route.
- The search door holds: the only production caller of `searchAll` is
the REST door (`git grep`, tests excluded). So
`packages/metadata-protocol/src/protocol.ts` is not in this diff.
4. **The empty-string premise is partly falsified.** Only import jobs
and search answered `?limit=` with their default. Export answered a
one-row export (`Number('') || 0` → `Math.max(1, 0)`), and history
answered zero events (`Number('')` = 0, forwarded). The rule applied is
the one `parseEnumParam` (runtime `query-param.ts`) states for the same
spelling:
- Where the old answer already was the absent answer, empty stays
absent.
   - Where the old answer was an invented `0`, empty is refused.
Reading it as absent on export and history would have turned those two
into a 10000-row export and the whole change log. That grows the window,
the widening this claim stops on, so the reader carries an explicit
per-door `emptyIsAbsent`.

No door accepts anything it refused before: none refused any
single-valued `limit` before this PR. No conforming value's answer
changes either, so the declaration stays `no (narrowing)`.

## Tests

The new file is
`packages/rest/src/rest-server-limit-param-parsing.test.ts` (43 cases).
Each refusal pins `status` 400, `code` `VALIDATION_FAILED`,
`fields[0].field` and `fields[0].code`, and that the service spy was
never called. Each lit control pins the argument the service received.
- Against unfixed `6780e34a`: 24 failed (every refusal row answered
200), 19 passed (every lit control).
- Against the fix: 43 passed.

One existing fixture was corrected in
`rest-server-closed-query-params.test.ts`. Its search closed-set loop
sent `limit: 'x'` and expected 200; it now takes a valid value per name,
as its export twin in the same file already does.

Runs at head `74898ed5`, after merging `origin/main`:
- **Rest suite:** `pnpm --filter @objectstack/rest exec vitest run
--project local --maxWorkers=2` → `Test Files 196 passed (196)`, `Tests
3327 passed | 1 skipped (3328)`.
- **Typecheck:** `pnpm --filter @objectstack/rest run typecheck` → exit
0. `tsc --noEmit` passes, and `check:test-typecheck: OK — ... 0 file(s)
/ 0 error(s)`.

Ablations went through `scripts/ablation-replace.mjs` in WRAP mode, run
after the fix was committed. Vitest reads `src/` through the relative
import, so no dist was involved.
- **Import jobs:** restoring `Math.min(200, Math.max(1, Number(q.limit)
|| 50))` landed on disk (anchor 1 → 0, blob `02ceccd1` → `cab07300`).
Result: `Tests 8 failed | 35 passed`, exactly the 8 import-jobs `limit`
refusal rows.
- **Search:** restoring `req.query?.limit ? Number(req.query.limit) :
undefined` landed (blob `02ceccd1` → `9b589c00`). Result: `Tests 4
failed | 39 passed`, exactly the 4 search refusal rows.
- Both restores were proven: blob == HEAD `02ceccd1`, `git diff HEAD`
empty.

Public surface: rest's `dist/index.d.ts` and `dist/index.d.cts`, built
from merge-base `bc80e162`'s `rest-server.ts` and from HEAD's, are
byte-identical (`cmp`). The restore was proven by blob hash. So no
importing package's types can move, and only rest's own tests are owed.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on the real diff gives 61 commands,
identical to the PM's list. All ran at `74898ed5` with exit codes
captured before any pipe. `--ran` reconciliation: `61 derived, 59 run, 2
NOT-MEASURED, 0 UNRUN`.
- **59 exit 0.** This includes `check:adr-0087-registration --base
origin/main` (`not-required (no-migration-prescription)` accepted),
`check:changeset-no-major`, `check:empty-changeset`,
`check:route-envelope`, `check:nul-bytes`, `check:doc-authoring`,
`check:published-files`, `check:issue-citations` and
`check:undeclared-dep-imports`.
- **NOT MEASURED: `check:dual-build-cjs-loads`**, exit 3 `PREREQUISITE
NOT MET`: 43 workspace packages have no `dist/`. Declared narrowing:
`require('./dist/index.cjs')` and `import('@objectstack/rest')` both
load and expose `RestServer`.
- **NOT MEASURED: `check:type-check-debt`**, exit 3 `PREREQUISITE NOT
MET`: the full closure is not built. See the `.d.ts` identity above; CI
builds the closure before this step.
- **`pnpm lint`** (the lane addition) ran as the full union, `eslint .
--no-inline-config`, at `74898ed5`: exit 0.

## First-party callers, measured

- `@objectstack/client` sends `limit` as written: `data.listImportJobs`,
`data.export`, `meta.getHistory`, `search`.
- objectui at its pin `f8a9d0fb`: `ImportWizard` sends `limit: 50`,
`ResourceHistoryPage` sends `limit: 100`, and the search and export
emitters send `limit` only when it is a number greater than 0. None
sends a value this PR refuses.

## Acceptance notes

- **Same-family reads left out of this diff.** Triage's note 3 keeps
them out, and the file is also held by another claim (PR objectstack-ai#20125).
  - Measured on the real routes with a spy protocol, each answering 200:
- `GET /meta/:type/:name/history?sinceSeq=abc` drops `sinceSeq` and
reads the log from the start (`rest-server.ts:8139`);
- `GET /meta/:type/:name/audit?limit=abc` drops `limit`, so the
producer's default 100 is served (`:8318`; declared `z.number()`, which
refuses `NaN`);
- `GET /search?perObject=abc` hands `perObject: NaN` to `searchAll`
(`:10709`).
- Read only, not measured: `GET /data/approvals/requests` `limit` /
`offset` drop a non-numeric value and serve the unpaged list (`:13601`).
Export `?page=` falls back to the 500-row chunk (`:10297`), which
changes chunking only, not the rows returned.
- **History's declared `limit` admits `1.5`, `0` and negatives.** The
door forwards them as before. Whether
`HistoryMetaItemRequestSchema.limit` should declare `int()` / `min()` is
the spec seat's question, and this PR takes no position.
- **Export and search range stay as they were:** the export floor of 1
and cap of 50000, and search's `[1, 100]` clamp. Both cards take no
position on bounds.
- **`GET /data/import/jobs?status=` is not checked against the declared
`ImportJobStatus`.** Read only, not measured, out of scope.
- **Merge:** `origin/main` was merged (no rebase) after PR objectstack-ai#20128 landed
on `rest-server.ts`, with no conflict. PR objectstack-ai#20125's hunks do not overlap
these doors.
- **CI-owned, not run locally:** the full `Test Core` shards, `Dogfood`,
`Build Core`, `Temporal Conformance`, and the workspace type-check
lanes.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…any driver call — a string, number or Map `where` on a multi-row update or delete rewrote or removed every row (objectstack-ai#20144)

Fixes objectstack-ai#20121
Clause-②: no (narrowing)

## Write verbs first — the base reading: a whole-table rewrite and a
whole-table delete

Measured on `origin/main` `a08e059c61` before any edit, through the real
`ObjectQL`, four rows seeded (two per owner), on `driver-memory` and on
`SqlDriver` (better-sqlite3 `:memory:`). The rows touched were read back
past the engine.

| call | no `SecurityPlugin` | `SecurityPlugin`, system context |
`SecurityPlugin`, RLS-scoped member |
|:--|:--|:--|:--|
| `update(o, {name:'Z'}, { where: 'amount > 100', multi: true })` | **4
of 4 rewritten** (both drivers) | **4 of 4 rewritten** (both drivers) |
refused by the driver, `INVALID_FILTER`, 0 rewritten |
| same with `where: 42` / `where: new Map(...)` | **4 of 4** | **4 of
4** | refused by the driver, 0 |
| `delete(o, { where: 'amount > 100', multi: true })` | **4 of 4
deleted** (both drivers) | **4 of 4 deleted** (both drivers) | refused
by the driver, `INVALID_FILTER`, 0 deleted |
| same with `where: 42` / `where: new Map(...)` | **4 of 4** | **4 of
4** | refused by the driver, 0 |
| either write, `where: [1, 2, 3]` | refused, `code`/`status` undefined,
0 | same | same |
| either write, same shapes, no `multi` | refused: "Update/Delete
requires an ID or options.multi=true" (no code), 0 | same | same |
| control: `where: { amount: { $gt: 100 } }` or `[['amount','>',100]]`,
`multi: true` | 2 of 4 | 2 of 4 | 1 of 4 (the member's own row) |

- **Path:** the multi-row path. `update()` and `delete()` lower at the
`[objectstack-ai#5158]` comment ("Lower before the by-id extraction below reads
`where.id`" / "Same ordering reason as update()"), then
`resolveEngineUpdateDispatch` / `resolveEngineDeleteDispatch` look for a
`where.id` only in an object `where`, find none, and return `{ kind:
'multi' }` on `options.multi`. The seeded AST carries the string, and
the driver's `updateMany` / `deleteMany` ignores it.
- **"Every row the caller can reach"** holds without `SecurityPlugin`
and under a system context with it. For an RLS-scoped caller it depends
on the value:
- A string, number or `Map` (and a `Date`, `Set` or `true`) is
AND-composed by the security middleware into `$and`, where the driver's
node gate refuses it: 0 rows.
- The empty string is NOT refused. The composition
(`security-plugin.ts`, `opCtx.ast.where ? { $and: … } : …`) reads a
falsy `where` as absent and drops it, so the member's `''` read all 2 of
their rows, and `update(multi)` / `delete(multi)` rewrote or deleted 2
of 4 on both drivers: every row the member could reach.
- **A second door stepped around, too:** the unscoped-write guard
(`dispatchUnscopedMultiWriteHooks`, the `sys_attachment` / `sys_comment`
predicate-less-write refusals) treats only an absent or `null` `where`
as unscoped, so a string / number / `Map` `where` skipped that guard
while the driver treated it as no predicate at all.

The re-grade to p0 is the seat's act; this PR lands the refusal.

## What changed (read from the code at the head of this branch)

`packages/objectql/src/engine.ts`, `lowerWhereFilterArray` only:

1. **A shape gate at the top of the seam.** A `where` that is not
`undefined`, not `null`, not an array and not a filter object is refused
with `invalidFilterError` (the package's existing ADR-0112 builder:
`code: 'INVALID_FILTER'`, `status: 400`, `httpStatus: 400`). All six
verbs (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`) call
the seam before they resolve a driver, so the refusal comes before any
driver call. The message uses the wire door's words: `VERB('OBJECT'):
'where' must be a filter object or condition array, received string
"amount > 100". It was not applied, and an unapplied filter would have
returned the unfiltered result set.` For `update` / `delete`, the last
clause reads `updated every row in scope` / `deleted every row in
scope`.
2. **The non-filter-array branch carries the same envelope.** Its
message is unchanged; it now throws through `invalidFilterError` instead
of a bare `Error`.
3. **The accept set** is decided by `isWhereFilterObject`: a non-array
object whose built-in tag (`Object.prototype.toString`, which reads
`Symbol.toStringTag` through the prototype chain) is `[object Object]`.
That covers a plain object, `Object.create(null)`, a class instance, a
`Proxy` of one, and an object from another realm. An object that
overrides `Symbol.toStringTag`, own or inherited, is refused and named
by its tag (H4 below).

No new export, no new error code, no change to the drivers, to the REST
door, to the `filter` alias or to the `having` region. The docblock hunk
draft PR objectstack-ai#20125 edits is not touched.

## H4 — every `where` shape, base answer and head answer

Measured on both drivers with no security; `find` / `update(multi)` /
`delete(multi)` shown (count and aggregate follow `find`).

| shape | base | head |
|:--|:--|:--|
| string, number, `Map` | dropped: 4 rows read / 4 rewritten / 4 deleted
| `INVALID_FILTER` / 400, 0 driver calls |
| non-filter array `[1,2,3]` | refused, `code` / `status` undefined |
`INVALID_FILTER` / 400, 0 driver calls |
| `Date`, `Set`, `true`, `''` | dropped: 4 / 4 / 4 | `INVALID_FILTER` /
400, 0 driver calls |
| `undefined`, `null` | no filter: 4 / 4 / 4 (declared unscoped) |
unchanged |
| `{}`, `[]` | no filter: 4 / 4 / 4 | unchanged |
| `Object.create(null)` with the filter | filters correctly: 2 / 2 / 2 |
unchanged, accepted |
| an author's class instance with the filter on own keys | filters
correctly: 2 / 2 / 2 | unchanged, accepted |
| an object overriding `Symbol.toStringTag` (own or inherited) with the
filter on own keys | filters correctly: 2 / 2 / 2 (contract review's
reading) | `INVALID_FILTER` / 400, `received Criteria` — the one
correctly-answering shape this narrows; no producer in the repo creates
one |

- `null` stays accepted: `findOne`'s no-predicate guard and the
unscoped-write detector both already read `null` as absent.
- `''` is refused, although the wire door reads a blank `?filter=` as
absent. At the engine, `''` deleted every row while the unscoped-write
guard read it as scoped. Under RLS it was not refused either: the
security middleware dropped it as absent, and the member's every
reachable row (2 of 4) was rewritten or deleted. The wire door's
normalizer deletes a blank `?filter=` before the engine sees it; other
REST paths were not measured for `''`.
- `findOne` keeps its own no-predicate refusal (no `code`) for `null` /
`undefined` / `{}` / `[]`, exactly as at base.

## H3 — other doors that take a caller `where` (census, base and head,
`driver-memory`, bad value `'amount > 100'`, control `{ amount: { $gt:
100 } }`)

| door | on this seam? | base | head |
|:--|:--|:--|:--|
| `createContext().object(o).find` / `.delete({ multi: true })` | yes,
forwards to the engine | 4 rows / 4 deleted | refused `INVALID_FILTER` /
400 (pinned) |
| analytics auto-bridge (`engine.aggregate(o, { where: filter })`) |
yes, by reading `service-analytics` `plugin.ts` | follows `aggregate` |
follows `aggregate` |
| `aggregate` `aggregations[].filter` | no, its own loop | string
dropped: every group counts all rows | unchanged — Acceptance notes |
| a `beforeFind` hook assigning `ctx.input.ast.where` | no, runs after
the seam | 4 rows | unchanged — Acceptance notes |
| a middleware assigning `opCtx.ast.where` | no, runs after the seam | 4
rows | unchanged — Acceptance notes |
| a `beforeUpdate` / `beforeDelete` hook assigning `input.options.where`
on `multi: true` | no | inert: the predicate path had already seeded its
AST (2 rows, same as control) | unchanged |
| `updateMany` / `deleteMany` / `upsert` / `distinct` on the engine | —
| not engine verbs (`upsert` and `distinct` are retired) | — |

## Tests (at `aa923a1f5c`, the code head, unless noted)

Head `2bd379d22a` changes only the changeset prose, after contract
review 5831634153. `engine.ts` (`4f5d28170b`) and both pins are
blob-identical to `aa923a1f5c`, so every test reading below stands for
the head.

- New unit pin
`packages/objectql/src/engine-where-shape-refusal.test.ts`, 47 cases,
all through a counting driver double: 6 verbs × 4 bad shapes, each
asserting `code`, `status`, the `VERB('deal')` prefix, zero driver calls
and an unwritten table; 6 × 2 controls; the scoped-repository door; five
accepted edge shapes; five refused edge shapes on `delete(multi)`. Run
together with `engine-filter-array-lowering.test.ts`: `Test Files 2
passed (2) · Tests 107 passed (107)`.
- New dogfood pin
`packages/qa/dogfood/test/engine-where-shape-refusal.test.ts`:
`update(multi)` and `delete(multi)` × the 4 bad shapes on `SqlDriver`
(better-sqlite3), reading the table through knex; plus two controls.
Against the **base** `objectql/dist`: `8 failed | 2 passed` (the 6
string/number/`Map` cells: "expected the engine to refuse, and it
answered"; the 2 array cells: `expected undefined to be
'INVALID_FILTER'`). After rebuilding `objectql`: `10 passed` (rerun at
the head: `10 passed`).
- `@objectstack/objectql` whole suite (both vitest projects), at
`6485915e34`: `Test Files 317 passed (317) · Tests 5499 passed (5499)`.
Between that commit and the head, only the new test file changed: one
repository case was added, and the double now honours `limit` and
refuses combinators. `engine.ts` did not change.
- `@objectstack/plugin-security` whole suite, at `6485915e34` against
the rebuilt `objectql` `dist/`: `Test Files 135 passed (135) · Tests
2685 passed (2685)`.
- Dogfood tests that exercise an engine `where`, at `6485915e34`, a
declared subset (CI's Dogfood Regression Gate runs all of them): the new
pin, `attachments-unscoped-delete-gate`, `bulk-widener-probe`,
`owner-anchor-and-bulk-writes`, `authored-row-write-scope`,
`comments-permission-matrix`, `attachments-permission-matrix`,
`owd-public-read-write-write-floor`, `hook-runas-fls`,
`showcase-crud-persona-matrix`: `Test Files 10 passed (10) · Tests 131
passed | 1 skipped (132)`. The skip is the pre-existing
`describe.skipIf(!organizationsAvailable)` in
`attachments-permission-matrix`.
- `pnpm --filter @objectstack/objectql run typecheck` green, including
`check:test-typecheck` (ledger held, 40 files / 234 errors / 65
signatures); the new test file is in the `tsconfig.test.json` program
(`--listFiles`). `pnpm --filter @objectstack/dogfood run typecheck`
green.
- `check-changeset-no-major --base a08e059`: no `major`.
`check-adr-0087-registration --base a08e059`: 1 declared-breaking
changeset, disposition `not-required (no-migration-prescription)`.

**Ablation**, run at `544762f243` (the 46-case pin, before the
repository case was added), both legs through
`scripts/ablation-replace.mjs` (anchor hits verified on disk, restore
proven by blob equality with `HEAD` and an empty `git diff HEAD`), with
the fix committed first:

1. Shape gate disabled (the `if (where !== undefined && …
!isWhereFilterObject(where))` line → `if (false)`): `23 failed | 23
passed (46)`. Exactly the 18 string/number/`Map` cells plus the 5
refused edge shapes turned red; the 6 array cells stayed green on the
array branch. Predicted direction, observed.
2. The array branch's `invalidFilterError(` → `new Error(`: `6 failed |
40 passed (46)`, exactly the six non-filter-array cells.

## Gates

- Derived from the real diff at `aa923a1f5c` with `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (4 paths vs merge base `a08e059c6`): **68
commands, all exit 0**, captured before any pipe. `--ran`
reconciliation: `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN`, a zero
derived from the recorded exit codes.
- The first pass at `c51cd3d02b` turned up two real reds, both in the
new test double: `check:objectql-double-limit` (the double's `find`
ignored the caller's `limit`) and `check:where-matcher` (its matcher
read a combinator as a field name). Both are fixed in `aa923a1f5c`. The
same pass had `check:dual-build-cjs-loads` at exit 3, `PREREQUISITE NOT
MET` (eight unrelated packages had no `dist/`). That is not a red: those
eight were built and the gate reran green (`105 published require entry
point(s) across 67 package(s) load`).
- `node scripts/check-issue-citations.mjs --base a08e059`: `every
citation this change adds resolves` (6 judged).
- Rerun at `2bd379d22a` after the prose patch: `check-changeset-no-major
--event` over this body, `check-adr-0087-registration` and
`check-issue-citations`, all exit 0 (quoted in the patch report).
- Not run locally, and left to CI: the repo-wide `pnpm lint`, the full
dogfood suite, `Build Core`, and `Temporal Conformance` (live PG and
MySQL).

## Acceptance notes

- **`aggregate`'s per-aggregation `filter` drops a string the same way
`where` did.** Base and head, `driver-memory`: `aggregations: [{
function: 'count', field: 'id', alias: 'n', filter: 'amount > 100' }]`
counts every row (control `{ amount: { $gt: 100 } }` counts one per
group). `AggregationNodeSchema.filter` is declared
`FilterConditionSchema`. The loop that walks it (`[objectstack-ai#10576]` in
`aggregate()`) is a separate door from `lowerWhereFilterArray`, so it is
outside this claim. No public door or real producer was measured: the
wire door parses `aggregations` through `AggregationNodeSchema`, and the
analytics `ObjectQLStrategy` produces objects.
- **A hook or middleware that rewrites the where after the seam**
(`ctx.input.ast.where` in `beforeFind`, `opCtx.ast.where` in a
middleware) is not re-checked: a string there reads every row (census
above). The written value is the hook's, not the caller's, and this seam
runs before either.
- **The RLS leg at base was refused by the driver, not by the engine,
except for `''`.** For a string, number, `Map`, `Date`, `Set` or `true`,
the security middleware wrapped the value into `$and`, where the
driver's own node gate answers `INVALID_FILTER`. That refusal is now
unreachable for those shapes, because the engine refuses first. The
empty string was dropped as absent by that composition (`opCtx.ast.where
? … : …`) and reached every row the member could reach; the engine now
refuses it too, before the middleware runs.
- **Per-verb consequence clause.** The message's last clause differs by
verb, because a write has no result set; the rest of the sentence is the
wire door's.

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…objectstack-ai#20155)

Fixes objectstack-ai#20129

Clause-②: no

## What was wrong

`GET /api/v1/meta/doc/:name` and `GET /api/v1/meta/doc` decide "may this
caller read the doc" (ADR-0046 §6.7) from two reads besides the doc
itself: the environment's books, and on the single read the doc corpus
those books claim over. Both reads mapped a thrown read to `[]`:

- `fetchAudienceBooks` answered `[]` on a fault. To the resolver that is
"no `{ permissionSet }` book anywhere", so an authenticated caller takes
the fast path (`allReadable`) and every doc is readable.
- The single read's corpus read answered `[]` on a fault. To the
resolver that is "no book claims this doc", so its audience is `org`.

Measured on `16c5a33fdd`, before this change, driving the real handlers
with a protocol double whose list read rejects. The caller is
authenticated and does not hold `crm_admin`:

| fault | `GET /meta/doc/crm_admin_runbook` | `GET /meta/doc` |
|---|---|---|
| none (control) | 403 `PERMISSION_DENIED` | `crm_intro` only |
| book read rejects | **200 with the gated body** | **lists
`crm_admin_runbook`** |
| corpus read rejects | **200 with the gated body** | (the list's own
doc read, 503) |

## The fix

The fix stays inside the existing `readAudienceBooks` / `readDocCorpus`
path. It adds no second resolver (ruling `5793362670`, item 1) and no
new error code.

- `fetchAudienceBooks` now throws the read's own fault instead of
answering `[]`. Its two callers are the `/meta/doc` list filter and the
`/meta/doc/:name` gate.
- On the `/meta/doc/:name` gate, a corpus-read fault now throws the same
way.
- Both faults reach the route's existing `handleRouteError`. The
comments this change made false are corrected: the `readMetaList` doc
and the app-nav arm's "Fails CLOSED" paragraph.

## The one open judgment: what the doc reads answer on a fault

The route suggested pruning. I chose the sibling's answer instead, which
is to propagate the fault. Sibling reading on `16c5a33fdd`, same double:

| door | book-read fault, `metadata-protocol` shape (503) | book-read
fault, untyped throw |
|---|---|---|
| `GET /meta/book/:name/tree` (bare `await`, always propagated) | 503
`SERVICE_UNAVAILABLE` | 500 `INTERNAL_ERROR` |
| `GET /meta/doc` with its OWN doc read failing | 503
`SERVICE_UNAVAILABLE` | 500 `INTERNAL_ERROR` |

After this change, `GET /meta/doc/:name` and `GET /meta/doc` answer a
book-read or corpus-read fault the same way. One fault gets one answer
across the docs doors, pinned by comparing each door's `status` + `code`
against the tree door's. Both codes are already in the ADR-0112 standard
catalog.

- **Not 403 `PERMISSION_DENIED`** (triage's suggested pin). That code
states an authorization verdict the server never reached: it would tell
a holder they hold nothing, and it makes an outage indistinguishable
from a denial (ADR-0110 D3's miss-vs-outage rule, one layer up).
- **Not "prune" on the list.** Without the books the gate can clear no
doc at all: any doc may be claimed by a set-gated book, and `public`
only comes from a book. So the pruned list is empty for every caller,
which is an invented "this environment has no docs" during an outage.
The app-nav arm prunes because a nav response is a composite and the
rest of it is still served; a doc list has no rest.
- **Logging:** the fault is handed to the caller, so per AGENTS.md
"Degradation log levels" no extra `logger.error` is added.
`handleRouteError` already logs the withheld 5xx.

The cost, stated: while the book store is failing, no doc is served or
listed, not even an `org` doc, and not even to a holder. An anonymous
caller with a book fault now gets the fault (503) instead of 401, which
is also what the tree door has always answered it. Healthy reads are
byte-for-byte unchanged.

## Reach with the shipped protocol

`metadata-protocol`'s `getMetaItems` throws
`metadataStoreUnavailableError` (503 / `SERVICE_UNAVAILABLE`) for every
`sys_metadata` read failure except "unprovisioned"
(`isMissingTableError`). This is pinned in its
`protocol.metadata-store-outage.test.ts` ("the PLURAL read stops
answering ..."). A marked `beforeFind` refusal from a sandboxed
`sys_metadata` hook travels its own refusal category
(`metadataReadFailureError`). Either way the book read throws after the
doc read succeeded, which is the path this change closes.

## Tests

New file: `packages/rest/src/meta-doc-audience-read-fault.test.ts`, 12
cases. It drives the real route handlers and asserts `status` + `code`
(ADR-0112 minimum), never the prose.

- **Book read rejects** (both fault shapes): the set-gated doc is not
served to a non-holder (no 200, no `item`, the body text absent from the
wire) and is not listed. The envelope equals the tree door's for the
same fault.
- **One book-read fault, three doors, one answer:** the doc read, the
doc list and the book tree all answer 503 `SERVICE_UNAVAILABLE`.
- **No doc is cleared without the books:** an `org` doc, and a holder,
are not served during a book fault.
- **Corpus read rejects** on the single read (both fault shapes): the
doc is not served, and the answer equals the tree door's. The test
asserts that the book read and the corpus read were each attempted once,
so the gate really reached the corpus read.
- **Controls, healthy reads:** 403 `PERMISSION_DENIED` plus absence from
the list for the non-holder, 200 with the body plus presence in the list
for the holder, and 401 `UNAUTHENTICATED` for anonymous.

## Ablation

The fix was committed first (`955d5a5c`). Each leg ran through
`scripts/ablation-replace.mjs`: the anchor must hit, and the landing is
proven on disk. The test imports `./rest-server.js` relatively, so the
mutation is read from `src/` and no `dist/` rebuild is involved.

| leg | mutation | predicted | observed | restore |
|---|---|---|---|---|
| A1 | `fetchAudienceBooks` back to `return 'fault' in read ? [] :
read.items;` | 6 red, the book-fault cases | **6 failed / 6 passed**,
exactly the book-fault cases | blob `ee61f7664c92` == HEAD, `git diff
HEAD` empty |
| A2 | corpus read back to `docReader('fault' in read ? [] :
read.items)` | 3 red, the corpus-fault cases | **3 failed / 9 passed**,
exactly the corpus-fault cases | blob `ee61f7664c92` == HEAD, `git diff
HEAD` empty |

Both went red in the predicted direction, with no inversions.

## Verification (HEAD `955d5a5c`)

- `pnpm --filter @objectstack/rest test` (the `local` project): 197
files passed, 3339 tests passed, 1 skipped. `test:repo`: 1 file, 8 tests
passed.
- `pnpm --filter @objectstack/rest typecheck`: exit 0. The new test is
in the `tsconfig.test.json` program (`--listFiles` count 1).
- `pnpm lint` (full repo): exit 0.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 61 commands off the merge base. 59 exited 0. 2 are
**NOT MEASURED** (exit 3, PREREQUISITE NOT MET), both needing a
whole-workspace build this container does not hold:
- `check:dual-build-cjs-loads`. A narrowed probe did load
`packages/rest/dist/index.cjs` with exit 0.
- `check:type-check-debt`. `@objectstack/rest` is not a ledgered
package, and this diff changes no exported type.
- `check-plugin-teardown-shape --self-test` first exited 3 because the
shallow clone lacked its pinned fixture commit. It exited 0 after
fetching that one commit.
- `--ran` reconciliation: 61 accounted, 0 UNRUN.

## Changeset

`.changeset/20129-docs-audience-fail-closed.md`, **patch** for
`@objectstack/rest`. This is a security bug fix in a released package
(AGENTS.md Post-Task Checklist 3). It adds no published surface: only
private methods change, the wire codes are ones these routes already
emit, and healthy-read answers are unchanged.

## Scope

- objectstack-ai#20130 remains open (the `/meta/app/:name` side doors). objectstack-ai#20139 remains
open (the bare `Number()` query reads). Neither is touched here.
- PR objectstack-ai#20125 landed on `main` as `8d1f7ab7` after this branch's base.
`git merge-tree --write-tree origin/main HEAD` is clean, and none of its
hunks are touched.
- **Out-of-scope finding, handed to the seat for filing, not fixed
here:** `GET /meta/doc/:name/layers`, the deprecated `GET
/meta/doc/:name?layers=true` and `GET /meta/doc/:name/published` serve
the set-gated doc body to an authenticated non-holder with healthy
reads. No §6.7 gate runs on those doors at all. This is the same "side
door skips the per-caller read gate" family as objectstack-ai#20130, but for a
different reason than this card (a gate that is absent, not a fault read
as absent).

## Acceptance notes

- `GET /meta/doc/:name/history` and `/diff` were not measured for the
same side-door question.
- No hand-written `content/docs/**` line is made false:
`content/docs/ui/doc-pages.mdx` states no fault behaviour.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ing on apiEnabled: false, export only where the export door admits it (objectstack-ai#20135) (objectstack-ai#20151)

Fixes objectstack-ai#20135

Clause-②: no

`apiOperations`, the operation set `/auth/me/permissions` (and
`ISecurityService.getEffectiveObjectPermissions`) attaches to each
entry, now offers exactly what the REST door serves this subject. It
used to disagree with the door in two places, both in the direction of
offering an operation the door refuses:

- **`enable.apiEnabled: false`.** The door answers `404
OBJECT_API_DISABLED` for every verb. The annotation ignored the switch:
it carried the object's whole closure, or no annotation at all (read by
a client as default-allow) when the subject's export stays allowed on an
otherwise unrestricted object.
- **The export slot** (seat pointer 5831905897).
`annotateEffectiveApiOperations` fell back to the MERGED `'*'` export
bit (`acc.allowExport ?? wildExport`). So a private object reached only
through a plain `'*': { allowExport: true }` was annotated `export`, and
so was an object the exporting set itself names without the grant. The
export door answers both `403 EXPORT_NOT_PERMITTED`.

The fix is in the producer, `annotateEffectiveApiOperations` in
`@objectstack/core`
(`packages/core/src/security/effective-object-permissions.ts`), as
triage directed: 「read the same resolver `enforceApiAccess` reads, so
the two cannot diverge」. The REST door, `packages/spec`
(`resolveEffectiveApiMethods`, `effectiveOperationsArray`,
`apiExposureDenialReason`), `checkObjectPermission`, the seed and the
folds are all untouched. No exported name or type changes.

## What changes

The annotation asks the door's own two questions, entry by entry:

- **The object half** is `canServeApiOperation`
(`@objectstack/spec/data`). It is the boolean face of
`apiExposureDenialReason`, the function `apiAccessDenialFromEnable`
(`enforceApiAccess`) turns into its 404 and 405. The served set is the
closure filtered through it. It judges `apiEnabled === false` first and
for every operation, so an API-disabled object is annotated `[]`. For
any other `enable` the filter is the identity, because the closure
already is what the door admits.
- **The export half** is `objectPermissionGrants(entry, 'allowExport')`:
read and `allowExport` on the entry itself. That is the export door's
conjunction, and `PermissionEvaluator`'s `export` branch documents it as
what the merged per-object entry answers. For the export bit, since
objectstack-ai#20083 and objectstack-ai#20134 the coverage passes put each set's `'*'` grant only on
the entries that set reaches, per posture, so the entry's `allowExport`
already is the per-set, per-posture answer and the merged-bit fallback
is gone. The read half of the conjunction is the entry's read bit, which
still carries the merged fold's read over-grant pre-registered for
objectstack-ai#20136. It moved no cell of the parity column below.

Which entries carry an annotation keeps the objectstack-ai#18931 / objectstack-ai#18990 rule: an
unrestricted object whose every operation is still served, `export`
included, gets none. What moved is the evaluation of "still served": an
API-disabled object never qualifies.

## H4: `apiOperations: []`, and the entry stays

- **What the client reads.** Nothing in this repo's SDK reads
`apiOperations`. `git grep apiOperations -- packages/client
packages/client-react` finds 0 lines; `@objectstack/client` only
re-exports the `GetEffectivePermissionsResponse` type. The reader is
objectui, which is **UNMEASURED** here because the sibling repo is not
in this container. From the spec contract
(`EffectiveObjectPermissionSchema.apiOperations`: "absent =
default-allow") and the objectui code quoted on objectstack-ai#18931 (`effectiveApiOps
? effectiveApiOps.includes('export') : true`), an array hides every
operation it does not list and an absent one hides nothing. `[]` is also
the shape a deny-all (`apiMethods: []`) object already carries, so the
client meets no new shape.
- **Why not drop the entry.** The entry carries the CRUD bits that
`current_user.can()` reads on the server, and it reads an absent entry
as "no grant". `apiEnabled` closes the API, not data access
(`enforceApiAccess`' docblock: "`apiEnabled` controls automatic API
exposure, not data access"). Dropping the entry would change a
server-side `can()` answer, which H5 rules out, and would contradict the
objectstack-ai#18990 ruling that every reachable object gets an entry.

## Measurement

**Door semantics used below.** An operation is "served" when neither
door refuses it: the object door (`404 OBJECT_API_DISABLED` / `405
OBJECT_API_METHOD_NOT_ALLOWED`) and, for `export`, the export door (`403
EXPORT_NOT_PERMITTED`). The operations measured are the ones the REST
door gates by name: `get`, `list`, `create`, `update`, `delete`, `bulk`,
`import`, `export`. `bulk` counts as served when any of `createMany` /
`updateMany` / `deleteMany` passes the door. "Offered" means the entry's
`apiOperations` lists the operation, or the entry has no annotation
(default-allow).

### Real stack (H1, H2): the real `GET /auth/me/permissions` against
real REST requests

This used a throwaway dogfood probe that is **not committed**. It ran
`bootStack` with the real `SecurityPlugin`, `orgContext: true`, 61
registered objects, and one authored app. The app holds `pq_open`
(public), `pq_exposed` (`apiEnabled: true`), `pq_hidden` (`apiEnabled:
false`), `pq_hidden_subset` (`apiEnabled: false` + `apiMethods:
['get','list']`), `pq_subset` (`apiMethods: ['get','list']`),
`pq_private` (`access.default: 'private'`) and `pq_private_hidden`
(private + `apiEnabled: false`). There are 0 such `apiEnabled: false`
objects in `examples/`, so the reach is authored objects only. Every
subject × every registered object × the 10 door requests above went
through the real REST routes, with bodies that cannot mutate. Base is
`16c5a33fdd`; head is this branch.

| subject (resolved sets) | offered but refused, present entries: base →
head | served but hidden: base → head | no-entry cells (unchanged) |
route == member |
|---|---|---|---|---|
| seeded platform admin (`admin_full_access`,
`organization_admin_no_bypass`, `member_default`) | 16 → **0** | 0 → 0 |
0 | yes |
| `admin_full_access` + `member_default` + a plain `'*': { allowExport
}` | 21 → **0** | 0 → 0 | 0 | yes |
| `member_default` | 0 → 0 | 0 → 0 | 134 | yes |
| member + explicit read on the app objects | 16 → **0** | 0 → 0 | 101 |
yes |
| … + a plain `'*': { allowExport }` | 20 → **0** | 0 → 0 | 101 | yes |
| member + explicit read and export on the app objects | 19 → **0** | 0
→ 0 | 101 | yes |
| member + a plain `'*'` with every bit and export | 11 → **0** | 0 → 0
| 15 | yes |
| member + `'*': { viewAllRecords, allowExport }` | 19 → **0** | 0 → 0 |
0 | yes |

- **H1 holds.** At base, `pq_hidden` read `[get, list, create, update,
delete, upsert, bulk, aggregate, search, import]` for the platform
admin, and had no annotation at all for export-allowed subjects.
Meanwhile every REST verb on it answered `404 OBJECT_API_DISABLED`. At
head every API-disabled object reads `[]`.
- **H2 holds.** For `admin_full_access` beside a plain export-only
`'*'`, `sys_secret` read `[get, list, aggregate, search, export]` and
`GET /data/sys_secret/export` answered `403 EXPORT_NOT_PERMITTED`. The
same holds for the authored private `pq_private`, which had no
annotation (default-allow) and a 403 export. The member with explicit
read beside the plain export wildcard shows the same on `pq_private`.
- **Entry-level diff, base-equivalent leg → head, all 8 subjects** (the
base-equivalent leg is the ablation below; its door readings equal true
base in all 8 subjects): 0 entries added or removed, 0 `allow*` bits
changed, **0 operations added to any annotation**, 74 operations
removed, 11 annotations added (9 × `[]`, 2 × the closure minus `export`
on `pq_private`).
- **No-entry cells** are objects the subject's map carries no entry for
(e.g. `member_default` on the `sys_*` objects it cannot read). Whether
an entry exists is the seed's question, and this PR leaves it alone. See
the Acceptance notes.

### Unit fixture (H3): the committed parity column

`plugin-security`'s `get-effective-object-permissions.test.ts` table
covers PR objectstack-ai#20145's 16 subjects plus 2 export-slot subjects. The 2 new
subjects are `admin_full_access` beside a plain export-only `'*'`, and
one set whose exporting `'*'` sits beside an explicit `crm_lead` entry
without the grant. Each subject is checked against every registered
object its map carries (the 11-object `REGISTERED` fixture) and every
door-gated operation. The oracle is `canServeApiOperation` for the
object half, the door's decision function, since plugin-security takes
no dependency on `@objectstack/rest`. For `export` it is the real
registered `svc.canExport`. The subjects also run through the existing
`can()` ↔ `checkObjectPermission` rows, green with `KNOWN_OVER_GRANT`
untouched.

| subject | offered but refused: base → head | served but hidden: base →
head |
|---|---|---|
| wall-less org admin · viewer · one set: plain `'*'` + narrower entry ·
platform admin · platform admin + wall-less · walled org admin · bare
modify-all · super-read + plain bits · one set: super-user `'*'` +
narrower entry | 7 → **0** each (`crm_hidden` × 7) | 0 → 0 |
| explicit entry beside a plain `'*'` · super-user `'*'` + export ·
super-read `'*'` + export · explicit entry beside a super-user `'*'` | 8
→ **0** each (`crm_hidden` × 8, `export` included) | 0 → 0 |
| **platform admin beside a plain export-only `'*'`** | 10 → **0**
(`crm_hidden` × 8, `crm_secret.export`, `sys_secret.export`) | 0 → 0 |
| **one set: exporting `'*'` + explicit `crm_lead` without the grant** |
9 → **0** (`crm_hidden` × 8, `crm_lead.export`) | 0 → 0 |
| member · export-only `'*'` beside a reader · a `'*'` granting nothing
| 0 → 0 | 0 → 0 |

Entry-level over these 18 subjects: 0 entries added or removed, 0
`allow*` bits changed, 0 operations added to any annotation, 92 removed,
7 annotations added. The core pins also hold an export grant without
read to no `export`, and read and export arriving from two different
sets to `export`.

## For `domain:cli` (the route) and `domain:services` (plugin-security):
cross-lane

No code in `plugin-hono-server` or `plugin-security` changes, only their
pins. The bytes `/auth/me/permissions` serves change for the affected
objects, and `getEffectiveObjectPermissions` returns the same map:

- an object declaring `enable.apiEnabled: false` reads `apiOperations:
[]` in every entry. That includes an unrestricted one whose export stays
allowed, which used to carry no annotation;
- `export` leaves the annotation where the export door refuses it: a
private object reached only through a plain wildcard export grant, an
object named without the grant by the set whose wildcard carries it, and
an entry granting export without read. A private, unrestricted object in
that position gains an annotation, its closure minus `export`.

Nothing else moves: no entry, no `allow*` bit, no added operation.
Shape, keys and route are unchanged, and so is every server decision.
`can()` reads the same bits.

## Clause-②

`no`, as the claim carries it. The served annotation narrows only toward
what the door already refuses (0 operations added, measured above), and
the door is untouched, so no request that succeeded now fails. No
exported name or type changes. `annotateEffectiveApiOperations` keeps
its signature. As an exported helper called standalone, it no longer
reads the map's `'*'`, a behaviour change named in the changeset. There
are 0 non-test callers in this repo: every hit of `git grep
annotateEffectiveApiOperations` outside tests is its definition, its
re-export, the composition and comments. `check-changeset-no-major
--base origin/main` exits 0 ("This diff introduces no `major` bump"),
and `check-adr-0087-registration --base origin/main` exits 0 ("1
non-breaking changeset(s) seen"). The changeset grades
`@objectstack/core` `patch`.

## Tests run, at head `f2904cd05b` (core `dist/` rebuilt from this
source)

Each suite ran under the verify lock, and each line quotes its `VERDICT
command-exit 0`:

- `pnpm --filter @objectstack/core test`: 53 files, 1353 tests passed.
`typecheck` exit 0, test layer included.
- `pnpm --filter @objectstack/plugin-hono-server test`: 27 files, 323
passed. `typecheck` exit 0.
- `pnpm --filter @objectstack/plugin-security test`: 135 files, 2718
passed. `typecheck` exit 0.
- Dogfood, 18 files, against the `dist/` closure built from this code
(`turbo run build --filter='@objectstack/dogfood^...'`, 64 tasks), in
two runs:
- 126 passed and 1 skipped: `organization-update-door`,
`me-apps-and-everyone-baseline`, `showcase-permission-projection`,
`showcase-permission-seeding`, `showcase-permission-zoo`,
`two-doors-permission`, `comments-permission-matrix`,
`attachments-permission-matrix`, `authz-conformance`;
- 135 passed: `showcase-private-owd`, `owner-anchor-and-bulk-writes`,
`showcase-crud-persona-matrix`, `showcase-client-liaison-fixtures`,
`showcase-fls-read-mask-strip`, `showcase-scope-depth`,
`showcase-scope-depth-write`, `showcase-scope-depth-fallback`,
`showcase-anonymous-deny-surfaces`.
- New and changed pins:
- core `effective-object-permissions.test.ts`: 7 new cases in a
`[objectstack-ai#20135]` block;
- plugin-security `get-effective-object-permissions.test.ts`: 2
subjects, the `apiOperations` column (one case per subject) and a
reported-cases case;
- plugin-hono-server `current-user-endpoints-effective-objects.test.ts`:
a route byte-equality case over an API-disabled and a private object;
- plugin-hono-server `effective-api-operations.test.ts`: 4 API-disabled
cases. Four existing cases were rewritten: one is **inverted on
purpose**, since it pinned the merged-`'*'` fallback itself, and three
called annotate on a map that never went through the per-set passes.
Those three now go through `buildEffectiveObjectPermissions` and assert
the same outcomes.

## Ablation

The mutation restored the base reading in one block: the merged-`'*'`
export bit back, and the door filter off. A string marker kept the dist
reading exact.

- It went through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob
`da81b319` → `33501e91`). Core's `dist/` was rebuilt, and
`ablation-dist-preflight` found the marker in 2 built files.
- **Observed direction: red.**
- core: 5 failed, 22 passed. The two right-direction controls stayed
green.
- plugin-hono-server: 6 failed, 27 passed, over its two pin files
together (`effective-api-operations.test.ts`, 29 cases, and
`current-user-endpoints-effective-objects.test.ts`, 4 cases).
- plugin-security: 16 failed, 36 passed. These are the 15 subject rows
with an API-disabled or export mismatch, and the reported-cases case.
The three subjects with no mismatch stayed green, as did every `can()`
row.
- The stack probe on the ablated build reproduced true base's door
readings in all 8 subjects.
- **Restore.** The blob equals HEAD, and `git diff HEAD` is empty (the
tool's own verdict). Core was rebuilt, and `ablation-dist-preflight
--absent` reports the marker absent from all 14 built files and a clean
tree. All three suites are green again: 27, 33 and 52 passed. A second
ablation leg, run to dump both legs' maps for the tables above, restored
the same way.

## Gates, at head `f2904cd05b`

- The list comes from `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`: 64 commands, derived from 6 paths
against merge base `16c5a33fd`. I ran each one and recorded its exit
code. **64 of 64 exit 0.**
- One needed a second run. `pnpm check:dual-build-cjs-loads` first
answered `PREREQUISITE NOT MET` (exit 3, not measured) because 8
packages had no `dist/`. After `turbo run build` of those 8, all cache
hits, it answered: "105 published require entry point(s) across 67
package(s) load; 660 emitted CommonJS file(s) parse".
- `dispatch-gates --ran`: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".
- `node scripts/check-issue-citations.mjs --base 16c5a33`: exit 0, 3
citations resolve.
- **Lint, as a proven narrowing.** `eslint --no-inline-config --format
json` over the 5 changed TypeScript files reports 5 files, 0 errors and
0 warnings. `eslint --print-config` resolves a config for each of them,
and eslint ignores the changeset. `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`, no typed rules, as its
own comment states), so this diff cannot move any untouched file's
verdict. The repo-wide `pnpm lint` is CI's.
- **NOT MEASURED locally** (CI owns them): Test Core, Dogfood Regression
Gate, Build Core, Temporal Conformance, the type-check lanes, and the
families `dispatch-gates` names outside its 64.

## DELIBERATE CORRECTION: three pending changesets (seat amendment 2,
5851881633)

Each change rewrites ONE sentence of a pending release note that this PR
makes false, following the objectstack-ai#20132 / objectstack-ai#20145 precedent. Every other line
of each file is byte-identical: `git diff --numstat f2904cd HEAD`
reads `1 1` on each file, and with the changed line deleted from both
sides `diff` exits 0. Check Changeset turns red on the foreign-changeset
rule by design, naming exactly these three files.

### 1.
`.changeset/18931-me-permissions-unrestricted-export-annotation.md`,
line 13 (blob `6eac3716` → `d49f56e3`)

- **Before:** 「an object that is unrestricted **and** keeps `export`
gets none.」
- **After:** 「an object that is unrestricted **and** keeps `export` gets
none — unless its `enable.apiEnabled` is `false`, which is annotated
`[]` since objectstack-ai#20135 because the REST door answers 404 for every verb on
it.」

### 2. `.changeset/18990-viewall-only-permissions-seed.md`, line 12
(blob `261e412f` → `727fbffa`)

- **Before:** 「… an unrestricted object whose export stays allowed still
gets no `apiOperations` (the entry itself is seeded since objectstack-ai#20134),
because for it the client's default-allow path is already right.」
- **After:** 「… an unrestricted object whose export stays allowed still
gets no `apiOperations` (the entry itself is seeded since objectstack-ai#20134),
because for it the client's default-allow path is already right — except
an object with `enable.apiEnabled: false`, annotated `[]` since objectstack-ai#20135
because the REST door refuses every verb on it.」

### 3. `.changeset/20134-super-user-entries-every-bit.md`, line 15 (blob
`a113da40` → `d2ee6a58`)

- **Before:** 「That entry carries no `apiOperations`, exactly as the
operation channel said nothing about the object before, so a client's
default-allow path for the operation set is unchanged.」
- **After:** 「That entry carries no `apiOperations` — unless the object
declares `enable.apiEnabled: false`, which is annotated `[]` since
objectstack-ai#20135 — so for every other such object the operation channel says what
it said before and a client's default-allow path is unchanged.」

**The new sentences are TRUE at this head.** An object with
`enable.apiEnabled: false` is annotated `[]` in every entry, and the
REST door answers `404 OBJECT_API_DISABLED` for every verb on it; both
were measured on the stack above. Every other unrestricted object whose
export stays allowed still carries no annotation. That holds for the
controls `pq_open` and `pq_exposed`, and for `crm_account` in the core
pins.

**This card's own changeset follows.** Its line 21 said those notes
"otherwise describe [such an object] as carrying none", which the
corrections make false. That one sentence now reads 「That includes an
unrestricted object whose export stays allowed, which used to carry no
annotation at all; this release's notes for objectstack-ai#18931, objectstack-ai#18990 and objectstack-ai#20134
name that exception.」 `git diff --numstat` reads `1 1`, and every other
line is byte-identical.

## Patch round 1, at head `ba66a482b2` (contract review 5851878434,
prose only)

Two commits sit on top of `f2904cd05b`: the three corrections, then this
card's changeset sentence. They touch only those four `.changeset`
files. Every code and test blob is identical to `f2904cd05b`: `git diff
--stat f2904cd HEAD -- . ':(exclude).changeset'` is empty, and the
five files carry blobs `da81b3192b`, `cd0367c631`, `ab113b718a`,
`070606df33` and `0ea90cbfed` at both heads. So the suites, ablation,
stack and gate readings above stand for the code. The branch was not
merged with `main`, and its merge base is still `16c5a33fdd`.

Gates re-run at `ba66a482b2`, with `--base 16c5a33` (the merge base)
and this body as the `--event`:

- `node scripts/check-empty-changeset.mjs --base 16c5a33`: **exit 1,
as expected**. It names exactly the three corrected files, each as
"present on the merge base and CHANGED by this PR -- this is somebody
else's release note":
`.changeset/18931-me-permissions-unrestricted-export-annotation.md`,
`.changeset/18990-viewall-only-permissions-seed.md` and
`.changeset/20134-super-user-entries-every-bit.md`. Its first line is "✓
No empty-frontmatter changeset introduced by this diff (4 declaring
changeset(s) added)". The class is DELIBERATE CORRECTION, not COLLISION,
so the base text must **not** be restored; a same-head contract-review
PASS is what confirms it.
- `node scripts/check-changeset-no-major.mjs --base 16c5a33 --event`
(this body): exit 0. It prints "✓ This diff introduces no `major` bump."
and "✓ LEVEL AXIS: this PR declares clause-② `no`, so no package here is
declared to have grown a published surface.", with declaration line
`Clause-②: no` and no direction arm.
- `node scripts/check-adr-0087-registration.mjs --base 16c5a33
--event` (this body): exit 0, "✓ check-adr-0087-registration: this PR
adds no declared-breaking changeset (4 non-breaking changeset(s) seen)."
- `node scripts/check-issue-citations.mjs --base 16c5a33`: exit 0, "✅
check-issue-citations: every citation this change adds resolves (or is a
declared cross-repo reference)." (3 judged, 3 resolve).
- `origin/main` has since moved one commit, to `8d1f7ab785` (objectstack-ai#20125). It
touches none of the six files in this diff, and in
`packages/core/src/security` it touches only `operation-private-keys.ts`
and its pin. The branch was not merged, as the seat directed.

## Acceptance notes

- **Entry presence is untouched, and it has its own mismatches.** On the
stack, a subject whose map has no entry for an object (the "no-entry
cells" column) leaves the client on default-allow for that object's
operations while the door refuses some of them. Two examples:
`member_default` on `sys_metadata` `create`, and `export` on objects it
cannot read. Those counts are identical at base and head. Which entries
exist is the seed's and the folds' question: it is excluded from this
claim, and objectstack-ai#20136 is next in this file. It is not filed here. `can()`
reads such an object as "no grant" on every verb, which is the
entry-presence ruling's territory (objectstack-ai#18990).
- **Pending release notes, corrected in this PR.** Seat amendment 2
(5851881633 on objectstack-ai#20135) ruled Q1 → B after contract review 5851878434.
The review measured `resolveEffectiveApiMethods({ apiEnabled: false
}).mode === 'unrestricted'`, so an API-disabled object with no
`apiMethods` is "unrestricted" in the resolver's own vocabulary, and all
three pending sentences read false for it at this head. Each gets a
one-clause DELIBERATE CORRECTION; see the section below.
- **Spec describe string.**
`EffectiveObjectPermissionSchema.apiOperations`' `.describe()` says
"Present only when the object tightens exposure via apiMethods". That
has been inexact since the export axis (objectstack-ai#3544), and is now inexact for
`apiEnabled: false` too. `packages/spec` is out of scope, so this is
noted only.
- **`history` / `restore` / `purge` / `search` are outside the parity
column** by construction. No REST route gates them by name. The spec's
`apiExposureDenialReason` admits every operation on an unrestricted
object, while `resolveEffectiveApiMethods` withholds the flag-gated
ones, and that difference lives in `packages/spec`. It is unchanged, and
the annotation keeps the closure's answer.
- The stack probe and the ablation dump were throwaway files and are not
in this diff. The tree was proven clean against HEAD after both.

Session `session_01Bvd69VPa6puiNzzPUroDBx` (the `domain:engine` seat 1
dispatch, round 22), on branch
`claude/issue-20135-api-operations-parity`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e SIZE limb, as it lifts a Tier H path (objectstack-ai#20159)

Fixes objectstack-ai#20153

Clause-②: no

## What this lands

The maintainer's ruling of 2026-09-27 (card objectstack-ai#20153, verbatim,
untranslated): 「所以阈值写死成 5000 行 , 维护者已经批准了就是可以合并。」

**An authorized APPROVED review now lifts the SIZE limb exactly as it
lifts a Tier H path.** "Authorized" is an account in
`GOVERNED_APPROVERS`; the predicate is the governed leg's own —
latest-decisive review per account, on ANY commit (the 2026-09-04
unpinning), dismissed / superseded / unauthorized approvals never count,
an unreadable review list fails closed. After the approval the owning
seat lands the PR through the queue; the maintainer's direct merge stays
the other landing.

Unchanged, as the card requires: the threshold (5,000), the strict
comparison, "generated files included", the FORK limb (no approval lifts
a fork), the post-merge sweep (it still lists every oversized landing —
it hands the predicate no approval), `GOVERNED_APPROVERS`, the Tier S
rule, and the exit-code register (no new code).

### Mechanism — one derivation, one predicate, imported

- `scripts/pm/check-governed-merges.mjs` (the predicate's home):
`testVerdict(paths, { size, approval })` takes the approval verdict as
DATA; `sizeVerdict(size, approval)` adds `lifted` (approvers, the commit
each approval was given on, the ruling's citation) and `approvalState`;
`sizeLiftFrom` reads only the verdict's `state` and its `approvals`
field; `sizeLimbFires(size)` = over the line AND not lifted;
`landsByHumanMerge` reads it. `exceeds` still reads true over the line —
the lift is a second fact beside the number, never a change to the
number.
- `scripts/pm/check-governed-queue-guard.mjs` (the queue leg): the SIZE
leg REUSES the governed leg's `authorizedApprovalVerdict` object for a
pull request that leg already read, else runs the same derivation on one
review read of its own — only for a pull request that is OVER the line
(a within PR and an unreadable size make no review read; both pinned
with throwing spies). `authorizedApprovalVerdict` now also returns
`approvals: [{ login, commitId }]` — the field only the authorized
reduction carries, so the generic `approvalVerdict` (which never asked
WHO) cannot lift. The guard spells no lift condition of its own; a
self-test pin reads its source to prove it.
- Exit codes: lifted → 0 (the SIZE block prints approver, commit, and
`objectstack#20153` with the ruling verbatim); no / unauthorized /
dismissed / superseded approval → 8, as today; unreadable size → 9, as
today; **an unreadable review list on an oversized PR → 8** (over the
line, no lift proven; the words say the list could not be read). Code 9
stays "the SIZE could not be read". Precedence governed-then-size is
unchanged.
- The seat-side `check-governed-merges.mjs --pr N` reads no reviews (it
never did, for the PATH limb either, and it cannot import the guard's
derivation — the module cycle is measured in the file). It answers the
SIZE question the way it answers the PATH question: over the line is
exit 3 and the words name the two landings the queue leg then holds the
PR to. On the same PR the two tools read one predicate; the queue
additionally holds the approval record — the same relationship they have
on a Tier H path today.

### PM mechanism assumptions — what the tree said

- Assumption 3 (the authorized-approval derivation is exported by
`check-governed-merges.mjs` and imported by the guard): **falsified**.
`authorizedApprovalVerdict` and `GOVERNED_APPROVERS` live in the guard;
the guard imports the sibling at module scope, and the reverse edge
(static or lazy) deadlocks (`Detected unsettled top-level await`,
measured in both files' headers). There is still exactly ONE derivation
— it stays in the guard; the sibling receives its result as data and
re-derives nothing. No second copy was added.
- Assumption 4 (protocol text): `AGENTS.md` class (c) stated the old
rule and is rewritten (same three-line block, +1 line; ratchet ceiling
1116, now 1106). `.claude/skills/pm-dispatch/SKILL.md:187` (「改动超 5000
行(含生成物)同换终局四件套」) and `references/landing-operations.md:58` (「超 5000
行(含生成物)照 Tier H」) already route an oversized PR to the Tier H terminal,
whose two landings line 189 / line 60 already spell (「授权批准 ⇒ 席位落地」,
「获授权批准后认领席落地」) — consistent, untouched, so no `.claude/**` file changes
in this PR. `scripts/pm/dispatch-gates.mjs`'s dispatch-time line said
"lands only by a HUMAN MERGE" and is rewritten (its self-test pin
updated).
- Assumption 5 (`--pr 20125` as a reading): NOT MEASURED from this
container — the changed-files walk answered HTTP 403 on page 2 and the
tool refused rather than answering on a subset (its documented
behaviour). By construction it would answer exit 3 with the words naming
both landings, and the queue leg would answer 0 on `os-zhuang`'s
approval on `e4ead748` (the self-test replays exactly that shape).
- Assumption 8 (unreadable review list): landed as exit 8, pinned in
both scripts.

### Grep table — `5,000` / `5000` / `HUMAN_MERGE_LINE_THRESHOLD` / `size
limb` over `scripts/pm/**`, `AGENTS.md`, `.claude/**` at `8d1f7ab7`

| file | verdict | note |
|---|---|---|
| `scripts/pm/check-governed-merges.mjs` | changed | predicate, header,
words, 13 new pins (battery floor 30 → 44) |
| `scripts/pm/check-governed-queue-guard.mjs` | changed | SIZE leg,
header, words, 13 new pins + 3 rewritten (battery floor 30 → 54) |
| `scripts/pm/dispatch-gates.mjs` | changed | `changedLineLines` OVER
text stated "lands only by a HUMAN MERGE"; its pin updated |
| `AGENTS.md` | changed | Multi-agent §7 class (c) stated "lands only by
a human merge" |
| `.claude/skills/pm-dispatch/SKILL.md` | consistent | line 187 routes
an oversized PR to the 四件套 terminal; line 189 names its two landings |
| `.claude/skills/pm-dispatch/references/landing-operations.md` |
consistent | line 58 「照 Tier H」; line 60 names Tier H's two landings |
| `scripts/pm/check-half-states.mjs` | consistent | "a human merge or an
authorized approval is the review record"; other hits are rate-limit
numbers |
| `scripts/pm/check-skill-line-ratchet.mjs` | unrelated | a
ceiling-ledger comment narrating the 2026-09-18 raise (history, not a
rule statement) |
| `scripts/pm/check-prior-rulings.mjs` | unrelated | "5,000 comments"
page cap |
| `.claude/skills/pm-dispatch/references/platform-readings.md`,
`references/rest-channel.md` | unrelated | rate-limit quotas (5000/h) |
| `board-snapshot.mjs`, `ci-failure.mjs`, `close-cards.mjs`,
`fleet-token.mjs`, `issue-create.mjs`, `label-write.mjs`,
`sweep-stale-finding.mjs`, `write-pace.mjs`, `check-dispatch-gates.mjs`
| unrelated | numeric coincidences (fixture ids, timeouts, quota
numbers) |

### New pins

`check-governed-merges.mjs` — battery "⭐ the SIZE predicate": an
authorized approval lifts (exceeds stays true, `landsByHumanMerge`
false); threshold / strict comparison / inclusion unchanged; the words
print approver, commit, `objectstack#20153` and the seat landing; exit
is the NOT-governed code; no approval / `unapproved` / unauthorized /
`unreadable` / the generic reduction (no `approvals` field) each still
fire; an approval under the line lifts nothing; a governed PATH and a
FORK head are untouched by the lift; the sweep still lists an oversized
landing; the not-lifted words name both landings.

`check-governed-queue-guard.mjs` — battery "⛔ objectstack-ai#19036: the SIZE line at
the queue": the governed leg's verdict object is REUSED (zero extra
reads, group exits 0); objectstack-ai#20125 replay — approval on the head and on an
older commit both exit 0 with two reads; the block prints approver,
commit, card and ruling; no / unauthorized / dismissed / superseded → 8
with the reason named; unreadable review list → 8 never 9; no review
reader → 8; reviews are read only over the line (within PR: none;
unreadable size: still 9, none); end to end governed clear + size lifted
→ 0, and with no approval → 8; `sizeReading` has four states; the
authorized verdict carries `approvals` and the generic one does not; the
guard's source spells no lift condition of its own; the remedy names
both landings and keeps the bypass-rules option (objectstack-ai#19344 battery
unchanged and green).

### Verification

Both self-tests green at head `52398a3b`: `check-governed-merges
--self-test` 454 assertions (was 441), `check-governed-queue-guard
--self-test` 292 cases (was 279).

Reverse verification (ablation, committed state,
`scripts/ablation-replace.mjs`, anchor hit 1 → 0, blob `4c5598c0d0c5` →
`c21c9c1a3338`, restored to the HEAD blob with `git diff HEAD` empty,
both legs): mutating the sibling's `sizeLimbFires` to ignore the lift
reddens the guard's self-test (6 of 292 cases fail: reuse, objectstack-ai#20125
replay, block words, end to end, four states) and the sibling's own (3
failures). Direction: 转红, as predicted.

Gate list from `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `52398a3b` — 40 commands, all exit 0,
reconciled with `--ran` (40 derived, 40 run, 0 NOT-MEASURED, 0 UNRUN;
every line carries its exit code):

| command | exit |
|---|---|
| `node scripts/check-ci-filter-parity.mjs` | 0 |
| `node scripts/check-closing-keyword-parity.mjs` (+ `--self-test`) | 0
/ 0 |
| `node scripts/check-comment-mask-corpus.mjs` | 0 |
| `node scripts/check-declaration-mirrors.mjs` (+ `--self-test`) | 0 / 0
|
| `node scripts/check-scripts-symbol-anchors.mjs` (+ `--self-test`) | 0
/ 0 |
| `node scripts/check-self-test-wired.mjs` (+ `--self-test`) | 0 / 0 |
| `node scripts/check-self-test-workflow-commands.mjs` (+ `--self-test`)
| 0 / 0 |
| `node scripts/check-skills-token-ratchet.mjs` (+ `--self-test`) | 0 /
0 |
| `node scripts/check-whole-set-label-write.mjs` (+ `--self-test`) | 0 /
0 |
| `node scripts/pm/bare-root-worklist.mjs --self-test` | 0 |
| `node scripts/pm/check-governed-queue-guard.mjs --self-test` | 0 |
| `pnpm check:agent-test-spelling`, `check:bash32-floor`,
`check:cli-command-ids`, `check:closing-target-claim`,
`check:cross-package-test-inputs`, `check:declared-population-live`,
`check:docs-audit-scope`, `check:driver-memory-census`,
`check:entry-guard`, `check:gitlink-declared`, `check:nul-bytes`,
`check:parse-guard` | 0 each |
| `pnpm check:pm-dispatch-gates` (894 s) | 0 |
| `pnpm check:pm-governed-merges`, `check:pm-governed-prose`,
`check:pm-skill-id-lint`, `check:pm-skill-ratchet`,
`check:pnpm-filter-targets`, `check:ratchet-remedy-authority`,
`check:refd-timer-probe`, `check:required-contexts`,
`check:watch-hint-literal` | 0 each |

Line budget: `AGENTS.md` 1105 → 1106 (ceiling 1116, headroom 10);
`SKILL.md` and `landing-operations.md` unchanged (headroom 0 on both,
untouched). `check-skill-line-ratchet` green.

Changeset: none — the diff touches `scripts/pm/**` and `AGENTS.md` only,
nothing any released package ships; `skip-changeset`.

## Acceptance notes

- `check-governed-merges.mjs --pr 20125` could not be read from this
container (page 2 of the files walk answered HTTP 403 through the
env-token channel; the tool refused rather than answering on a subset).
Not a defect; the container's credential asymmetry.
- The sweep's `sizeCell` does not annotate an oversized landing with the
approval that lifted it (the card allows, does not require, that note);
the sweep reads `merged_by`, not reviews, so adding it would add a
review read per oversized row. Left as is.
- `check-skill-line-ratchet.mjs` carries a ceiling-ledger comment
narrating the 2026-09-18 ruling as "takes the same terminal"; it is
history of a count, not a rule statement, and is untouched.

## 维护者速读(草稿)

**改了什么**:队列守卫的「超 5000 行」这一腿,现在和受管路径(Tier H)一样,承认你(或 `GOVERNED_APPROVERS`
里的账号)的 APPROVED 审查:批准过就放行,由认领席走队列落地;你直接合并这条路不变。阈值
5000、严格大于、含生成物,一个都没动;fork PR 不受此影响;事后审计照样把超 5000 行的落地列出来。

**为什么改**:你 2026-09-27 的裁决「所以阈值写死成 5000 行 , 维护者已经批准了就是可以合并。」——objectstack-ai#20125 已获
os-zhuang 批准仍被队列两次踢出,就是这条旧规则(「只认人工合并」)造成的。

**风险与代价(含回滚)**:代价是队列对每个超线的 PR 多读一次 review
列表(已被治理腿读过的直接复用,不重复读);批准后再推的提交不再被这一腿复审——与 Tier H 路径已接受的成本同形。review
列表读不到时按「未解除」拒收(退出码 8),不会误放。回滚 = revert 本 PR,两份脚本的自测各自变红,不会静默。

**席位意见**:(留空,由席位定稿)

**你要做的**:一个动作——本 PR 触及 `AGENTS.md`(Tier H),请 APPROVE 本
PR(或直接合并);批准后认领席走队列落地。

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

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

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation needs:contract-review protocol:data protocol:system size/xl tests tooling

Projects

None yet

2 participants