Skip to content

fix(plugin-approvals): sys_approval_request declares its per-caller viewer block under attachedOnRead (#22387) - #22479

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22387-approval-viewer-attached-on-read
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22387-approval-viewer-attached-on-read

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22387
Clause-②: no
Part of #22211

What this does

sys_approval_request declares its per-caller viewer block (#3310) under ObjectSchema.attachedOnRead (#22386, PR #22425), in the shape ruling 6070963704 on #22211 names:

attachedOnRead: {
  viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' },
},

With the block declared, the shared build validator resolves record.viewer on this object and judges record.viewer.LEAF against the declared leaves. The object's own 8 action visible predicates pass. A misspelt leaf is still refused, and the refusal names the declared leaves. The declaration's second reader (ADR-0049) is a conformance test. It drives the real ApprovalService and holds the served block's keys, and the runtime type of each value, to the declaration read from the object.

file change
packages/plugins/plugin-approvals/src/sys-approval-request.object.ts the declaration and its comment; the action design note now points at it
packages/plugins/plugin-approvals/src/sys-approval-request-viewer.conformance.test.ts new: the conformance test
packages/plugins/plugin-approvals/src/sys-approval-request-attached-on-read.test.ts new: the validator pin
packages/plugins/plugin-approvals/package.json, vitest.config.ts, pnpm-lock.yaml @objectstack/lint joins the devDependencies, aliased to source for vitest (anchored regex, the platform-objects precedent), so the pin reads the current rule and not a lint/dist build (check:test-source-alias)
.changeset/22387-plugin-approvals-viewer-attached-on-read.md patch for @objectstack/plugin-approvals

Not touched: approval-service.ts, so the per-caller viewer semantics (#3310) do not change. No other read attachment is declared (decision_progress, pending_approver_groups, the flow steps). No packages/spec, lint, formula, mcp or content/docs edit.

Reproduction

The object was judged on a stack of { objects: [SysApprovalRequest] }, through validateStackExpressions and runAuthoringRules('build'). This was a one-off tsx probe, deleted after the run. Before = the branch base c512c255c; after = this branch.

reading before after
validateStackExpressions 8 issues, one per action: unknown field `viewer` on `sys_approval_request` at object 'sys_approval_request' · action 'NAME' visible for approval_approve, approval_reject, approval_reassign, approval_send_back, approval_request_info, approval_remind, approval_recall, approval_resubmit 0
runAuthoringRules('build') 29 findings: 8 errors (all expression-invalid, the same 8 sites), 21 warnings 21 findings: 0 errors, the same 21 warnings (1 title-format-retired, 20 field-group-undeclared, pre-existing)
approval_send_back rewritten to record.viewer.can_actt (refused as above) 1 issue: unknown field `viewer.can_actt` on `sys_approval_request` (the read attachment `viewer` declares `can_act`, `can_override`, `is_submitter`) — did you mean `viewer.can_act`?

The same reading through the public door, os validate (the built CLI on this branch). The probe was a scratch defineStack config holding this object as shipped, plus one record-change flow on it (see F1); its control leg drops the attachedOnRead key from the object, which is the base state:

os validate object as base (no attachedOnRead) object as this branch
exit 1, Author-time rules failed (9 issues): the 8 action visible predicates plus the flow condition, each unknown field `viewer` on `sys_approval_request` 0, Validation passed, warnings only

The PM's mechanism assumptions, as measured: 1 holds (8 refusals on origin/main). 2 holds (strict parse and the collision refusal pass, the 8 pass, and the misspelt leaf is refused naming the leaves). 3 holds: attachViewers emits exactly can_act, can_override, is_submitter, all boolean, for all 5 caller shapes on both doors, and no extra key. 4 holds for the resolved type, with a nuance in the Acceptance notes. 5 is the door table below.

The two field-existence doors (scope note 6075461181)

Both doors were measured with a probe on a real ObjectQL over SqlDriver (better-sqlite3, in memory). The plugin's own objects were registered from its manifest. The probe was deleted after the run.

door builds record.* from reaches an expression on sys_approval_request? reading threaded?
service-automation: the flow-registration resolver, setObjectSchemaResolver (plugin.ts:1185) registry.getObject(name).fields Yes. Every flow whose start node names objectName: 'sys_approval_request' is checked against it, and nothing refuses a record-change flow on this object. No shipped flow is one: git grep finds none under packages/ or examples/. The resolver was wired exactly as plugin.ts wires it, with the start condition record.viewer.can_act == true. The door logs unknown field `viewer` on `sys_approval_request` as an advisory, before and after this PR, and registration is not refused. The record such a flow binds is a stored row. The probe checked three rows: the afterUpdate hook row the record-change trigger reads as ctx.result; an engine.find row, which is what the generic data door and a type: 'flow' action's subject load read; and getRequest's row. The first two carry no viewer key; getRequest's row does. Executing the flow over the hook row fails: condition failed to evaluate as CEL: No such key: viewer. No, on purpose. The door's verdict is the true one for a flow, because the flow's record never carries viewer. Threading the block would make the door accept a condition that fails on every run. The build-side disagreement this leaves is Acceptance note F1.
packages/mcp: the validate_expression tool (mcp-http-tools.ts:676) describeObject(name).fields No, in every shipped composition. The tool's guard refuses any sys_ object before it reads fields, unless the host registers the tools with allowSystemObjects: true. No shipped host does: the runtime's HTTP door passes only grantedScopes (packages/runtime/src/domains/mcp.ts:134), and the stdio plugin passes no options (packages/mcp/src/plugin.ts:656). Through MCPServerRuntime.handleHttpRequest, with a bridge that maps the object the way the runtime's describeObject does. With the shipped options it answers Object "sys_approval_request" is a system object and is not exposed via MCP. Only with allowSystemObjects: true does it reach field existence, answering unknown field `viewer` (unknown-field). No. packages/mcp is domain:cli's and is not edited here. See Acceptance note F2.

Pins and ablations

Every ablation went through node scripts/ablation-replace.mjs, in its wrap mode, under os-verify-lock. Each mutation was proven to land on disk by anchor count and blob change, and each restore was proven by blob equal to HEAD plus an empty git diff HEAD. The subject is this package's own src/, imported relatively. @objectstack/lint is aliased to source, so no rebuild sits between a mutation and the run.

pin holds ablation red, as observed restore
sys-approval-request-attached-on-read.test.ts (4 cases) The object's visible predicates that read record.viewer pass validateStackExpressions, runAuthoringRules('build') (stack prepared with normalizeStackInput and ObjectStackDefinitionSchema, as os build / os validate prepare it) and the object save door's runRuntimeAuthoringRules({ type: 'object' }). A misspelt leaf is refused at all three, naming every declared leaf. Control: with the declaration taken away, every reader is refused again. The attachedOnRead block deleted from the object (anchor 1 → 0, blob c4b45c28b39d → fc171e176107) 3 of 4 red. "every predicate passes" fails with 8 unknown field `viewer` on `sys_approval_request` issues, where it expects []. The misspelt-leaf case expects 1 refusal and gets 8. The population case gets 0 declared leaves. The control stays green, as it must: its verdict is the same either way. blob c4b45c28b39d == HEAD, git diff HEAD empty
sys-approval-request-viewer.conformance.test.ts (7 cases) For 5 caller shapes (submitter, pending approver, override actor = platform admin, system context, the approver on the finalized request), through both getRequest and listRequests: the served viewer keys equal Object.keys(SysApprovalRequest.attachedOnRead.viewer); each value passes the runtime test for its declared type; each shape is marked by its one true leaf; and across the battery every boolean leaf is observed both true and false. the same deletion, same run 6 of 7 red ("declares the block", then each of the 5 shapes on emitted keys). The both-values case passes vacuously with no declared leaf, which is why the "declares the block" case exists. as above
conformance as above attachViewers serves can_act as heldSlot(...), dropping !== undefined (blob 9a1822845a2f → 86ee0cb9a41a) 4 of 7 red, for example a current pending approver via getRequest: `can_act` declared boolean, served "u_app" and can_act across the battery: expected [ false, 'u_app', undefined ] blob 9a1822845a2f == HEAD, git diff HEAD empty
conformance as above attachViewers also emits can_comment: true 5 of 7 red, every shape: emitted keys: expected [ 'can_act', 'can_comment', … ] to deeply equal [ 'can_act', 'can_override', … ] blob 9a1822845a2f == HEAD, git diff HEAD empty

The first attempt at the extra-key ablation was refused by ablation-replace before it measured anything: its replacement contained the anchor, so the anchor count did not drop. The tool restored the file to HEAD. The rerun above used a replacement that does not contain the anchor.

Tests

All runs below are at HEAD 506350e9f, the last commit on this branch. Heavy runs went through os-verify-lock; its VERDICT command-exit line is quoted.

  • Build. pnpm --filter '@objectstack/plugin-approvals^...' --filter '@objectstack/lint...' build and pnpm --filter @objectstack/plugin-approvals build both gave VERDICT command-exit 0. The dist-reading gates ran after turbo run build --filter=!@objectstack/docs, which built 72 tasks with 71 cached (VERDICT command-exit 0).
  • Package tests. pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: Test Files 64 passed (64), Tests 916 passed (916), VERDICT command-exit 0. The two new files contribute 4 and 7 cases.
  • Typecheck. pnpm --filter @objectstack/plugin-approvals typecheck: VERDICT command-exit 0, with check:test-typecheck: OK — … 8 file(s) / 324 error(s) / 27 pinned signature(s) held in test-typecheck-debt.json. The ledger is unchanged, and the two new test files compile with zero errors.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 77 families from this diff. All 77 ran, each with its exit code captured before any pipe, and all exited 0. check:i18n and check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, no dist/) and exited 0 when rerun after the build. Reconciliation (--ran): 77 derived famil(ies) accounted for — 77 run, 0 NOT-MEASURED (a DERIVED zero — all 77 recorded an exit code and none of them is 3). Among them: check:test-source-alias OK (the new alias), check:engine-double-contract exit 0 (no engine double was added; the conformance test runs on a real ObjectQL), check:workspace-manifest-cycles OK (517 edges, no cycle), check:type-check-debt OK, and check:nul-bytes OK.
  • Lint, a declared narrowing. pnpm lint (eslint . --no-inline-config) is CI's run. Locally I ran the same binary and flags over the diff's lintable files. (1) Which files are lintable comes from eslint itself: the 4 changed .ts files are lintable, while package.json, pnpm-lock.yaml and the changeset answer File ignored because no matching configuration was supplied. (2) The --format json output reports files 4, errors 0, warnings 0. (3) This diff cannot change any untouched file's verdict: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules) and no cross-file import/ rules.
  • Not run locally. The full turbo test sweep belongs to CI. main has moved 4 commits since the base c512c255c (rest, auth, fleet-write). None touches this diff's packages, so the branch was not merged.

Acceptance notes


Generated by Claude Code

claude added 3 commits October 9, 2026 11:48
…viewer block under attachedOnRead

The object's 8 action `visible` predicates read `record.viewer.*`, a block
`ApprovalService.attachViewers` attaches per caller on read. Declared under
`ObjectSchema.attachedOnRead`, the shared build validator now resolves
`record.viewer` and judges its second segment against the declared leaves.

A conformance test drives the real service on a real ObjectQL over SqlDriver,
for every caller shape `attachViewers` serves, through `listRequests` and
`getRequest`, and holds the served keys and each value's runtime type to the
declaration read from the object.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
… the build pair and the object save door

Runs the object's own action `visible` predicates through
`validateStackExpressions`, `runAuthoringRules('build')` (the stack prepared
the way `os build` / `os validate` prepare it) and the object save door's
`runRuntimeAuthoringRules({ type: 'object' })`: every predicate that reads
`record.viewer` passes, a misspelt leaf is refused at all three naming the
declared leaves, and with the declaration taken away every reader is refused
again. `@objectstack/lint` joins the devDependencies, aliased to source in
vitest so the verdict is the current rule's.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

Coarse fallback — 6 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 dee7692f0bf5637c5c35609b9a27d42fe506b85d → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 13:00
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 13:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 3403be8 Oct 9, 2026
48 of 51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22387-approval-viewer-attached-on-read branch October 9, 2026 13:37
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 size/m tests tooling

Projects

None yet

2 participants