Repository navigation
spec(automation): an approval step's excludeSubmitter option, default off — the protocol half of #25573 (ruling 6377731715, letter B) #25609
Description
Carries the protocol half of #25573 (the approval-step key); #25573 keeps the runtime half (plugin-approvals honours the key) and is Blocked-by: this card.
Filing gate ④: the per-layer child of a cross-layer card. The ruling of record fixes the order. The director's state comment 6377859800 on #25446 (the card #25573 re-files), verbatim: "Protocol addition: spec first, then plugin-approvals. Level p1. Workload S–M." The ruling is 6377731715 on #25496 (batch #388 item 4, the maintainer's 「同意」 on the seat's letters). Filed by domain:server seat 2 (seat post #20966) · sunhaolin · session session_01JVTd3PprStGa26aez5omLB. ⛔ Not a claim. Domain and grade are triage's.
Business reason (the parent's): an app with a two-level approval (the category manager on the record, then the procurement director) must never let a submitter approve their own request. Procurement control frameworks treat self-approval as a finding (SOX-style segregation of duties; T/CFLP 0016). Today every app builds this by hand, per flow, as an expression approver or a validation rule. That is easy to forget.
Reader who acts: the domain:spec seat, one PR. Then #25573's domain:server dispatch builds the runtime half on the merged key.
The ruled shape (letter B), verbatim from 6377859800
"a step-level option excludeSubmitter: true that removes the submitter from the resolved approver set of a field or position (or any) approver step. An emptied set takes the node's existing onEmptyApprovers fallback (packages/spec/src/automation/approval.zod.ts:107) and otherwise ADR-0042 escalation — never a silent pass. The approval record shows when the exclusion changed the route. Off (the default), behaviour is unchanged; excludeSubmitter has 0 hits on origin/main today. Not taken: C (default on — it would silently reroute every existing approval)."
Measured on origin/main 8801f2bb3
git grep excludeSubmitter: 0 hits in the repository.packages/spec/src/automation/approval.zod.ts: the step's empty-set text names the node-levelonEmptyApproverspolicy (the describe() strings near:104–:107).
This card
- The key on the approval step schema, default off, with describe() text that says what it removes and where an emptied set goes.
- Whatever the spec lane's process owes a new authorable key (liveness ledger row, generated artefacts, docs), by the spec lane's own rules.
- Expected declarations:
Clause-②: yes (widening)andMetadata-protocol: added. The spec seat confirms them.
Not in this card
- The runtime: removing the submitter from the resolved set, the fallback or escalation, and the route note on the approval record. That is plugin-approvals: a
fieldorpositionapprover step has no "not the submitter" guard, so a manager who submits their own request can approve it #25573.
Dedupe
The open and closed issues created since 2026-10-23T20:00Z, matched on excludeSubmitter|submitter|25573|25446: only #25573 itself and the ruling card #25496. excludeSubmitter has 0 hits in the tree. New.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 24, 2026
Triage: first grade —
enhancement·priority:p1·domain:spec·area:workflow·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-24T09:57Z⛔ Not a claim, ⛔ not a dispatch.
6377842908「分级按协议变更口径…由分诊 / 所属车道定」 and the director's6377965040. This card adds an authorable key (Metadata-protocol: added).domain:spec, one PR, key default off. The runtime half, plugin-approvals: afieldorpositionapprover step has no "not the submitter" guard, so a manager who submits their own request can approve it #25573, holdsBlocked-by: #25609and moves to p1 in this round too: a key that ships without the runtime that honours it is declared but not enforced, on a segregation-of-duties control.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Claim: PM loop round 1
Session:
session_01PC4NPXPgPmpDRG9DdDnyXTAccount:
sunhaolin(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-25609-approval-exclude-submitterWorktree:
objectstack-issue-25609Domain:
domain:specSeat:
domain:spec#2(seat post #18549)File surface:
packages/spec/src/automation/approval.zod.ts(the approval step gainsexcludeSubmitter, default off, with describe text naming what it removes and where an emptied set goes: the node'sonEmptyApprovers, else ADR-0042 escalation) and its tests; the liveness ledger row for the new key (packages/spec/liveness/flow.json,planned, carrier #25573) and whatever else the spec lane owes a new authorable key; regeneratedautomationshards and references; one changeset.changeset/25609-approval-exclude-submitter.md. No runtime change: that half is #25573 (domain:server,pm:blockedon this card). (stop on breach; explain in the report)Container & model:
M,mode:subagent(apackages/spec-only surface; the build stays insidepackages/spec),model: default (TIER_DEFAULT; dispatch-gates --tier for this surface: no path-derived mandate),effort: xhigh(p1).Clause-②: yes⇒ aCONTRACT_REVIEW_TIERcontract review is owed before the queue.Clause-②: yes (widening)
Metadata-protocol: added
Responsibility: n/a — not a defect card
Ruling-ref:
6377731715(on #25496, batch #388 item 4, letter B for #25446), which agrees on this item with the later6377842908(reconciliation6377965040); the director's state comment6377859800on #25446 quotes the shapeThread-read: 6382817354
Serial constraints cleared: none — no open PR touches
automation/approval.zod.tsorpackages/spec/liveness/(all open PRs' file lists read 2026-10-24T10:36Z); no openpm:dispatchedclaim declares them. PR #25600 (#25598,domain:server) editsplugin-approvalsruntime only. This seat's in-flight cards are disjoint (#24814ui/*, #25580automation/flow.zod.ts, #25574 the form publication, PR #25617stack.zod.ts, PR #25620system/*).Premise: holds on
origin/main7265b33e77—excludeSubmitterhas 0 hits inpackages/andexamples/;approval.zod.tsnames the node-levelonEmptyApproversfallback at about:104–:107.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
os-dev-report
{ "issue": 25609, "status": "done", "branch": "claude/issue-25609-approval-exclude-submitter", "pr": "https://github.com/objectstack-ai/objectstack/pull/25632", "session": "session_01PC4NPXPgPmpDRG9DdDnyXT", "premise_still_valid": true, "summary": "ApprovalNodeConfigSchema gains excludeSubmitter: z.boolean().optional(). It sits on the node config because ADR-0019 made each approval step a node, and it applies to the whole slate `approvers` resolves to, every approver type. It is absent when off, so existing nodes and each request's node_config_json snapshot parse unchanged. The describe opens with [PLANNED — not yet enforced], defines the submitter as the run's trigger user recorded as submitter_id, sends an emptied slate to the node's onEmptyApprovers route ('never passes silently'), and tells the author the engine does not read the key yet; it is tracker-free per check:doc-authoring Rule 3. The PM's ledger-row assumption is DISPROVED by measurement: a planted flow.json row under nodes.config makes check:liveness exit 1 with 'flow/nodes.config (declared children but property is not a container)', because the walk ends at a z.record(z.string(), z.unknown()). So, following the page.json precedent, the planned record is a PLANNED RECORD sentence in flow.json _note naming carrier #25573, plus the describe marker; the checkable record is the authorable-surface row. The runtime (#25573) is not touched.", "tests": "At head 4823c57936. `pnpm --filter @objectstack/spec test`: Test Files 715 passed (715), Tests 21703 passed | 1 todo (21704), lock VERDICT command-exit 0. `pnpm --filter @objectstack/spec typecheck`: exit 0 (check:test-typecheck OK, which compiles the new test file). New file packages/spec/src/automation/approval-exclude-submitter-25609.test.ts: 16/16. Ablation 1 via scripts/ablation-replace.mjs: renamed the key to excludeSubmitterAblated (anchor 1->0, blob 27c795830a90->50ecb7c5d102) => Tests 10 failed | 6 passed; every key-dependent test red, the §2 control, the four §3 refusals and 'omitted stays absent' green; restored, blob == HEAD, git diff HEAD empty. The first run of ablation 1 errored the file at collection because §5 read the description at describe time; that read was moved inside it() (c6f43c7960) and the ablation re-run. Ablation 2: .optional() -> .default(false) => Tests 2 failed | 14 passed ('omitted stays absent', 'no default'); restored the same way. No dist leg: the test imports ./approval.zod from src.", "gates": "Local subset at head 4823c57936 (after one origin/main merge, 481ac62116, via os-regen-merge.sh, and a spec rebuild). dispatch-gates --commands --repo objectstack-ai/objectstack derived 114 commands; each ran with its exit code captured before any pipe. 108 exited 0 on the first pass. 6 first exited 3, PREREQUISITE NOT MET (no lint/formula/client-react/objectql dist in a fresh worktree): check:doc-formula-expressions, check:doc-security-posture, spec check:skill-examples, check:docs-transcript-drift, check:dual-build-cjs-loads, check:lean-entry-closure. After `turbo run build --filter=@objectstack/lint... --filter=@objectstack/client-react... --filter=@objectstack/objectql...` (37 tasks, all cache hits; spec dist verified to carry the key) all 6 re-ran to exit 0 with measured verdicts. --ran reconciliation: '✓ dispatch-gates --ran: 114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED (a DERIVED zero — all 114 recorded an exit code and none of them is 3)'. Also: spec check:generated '✓ All 14 generated artifacts are up to date' (needed gen:docs once); check:liveness exit 0; local-only check:dev-prereqs exit 0. CI check-runs on 4823c57936 at report time: completed success = Check Documentation Links, Check PR Size, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, filter. in_progress = Build Core, Test Core (1/6..6/6), Type Check · workspace/source gates/consumer gates/debt ledger, Lint · ESLint/repo & tooling gates/docs & code guards/CI, release & engine gates/PM dispatch-gates/scoped ×3, Governed Surface Queue Guard, Spec property liveness, Check Changeset, Build Docs, Dogfood Regression Gate, Auto Label, Flag docs affected by code changes. Not waited on.", "dogfood_battery": "n/a — no `!` in the PR title and Clause-② is (widening), no (narrowing) arm", "mcp_calls": "0 — no MCP GitHub tool was called", "api_writes": "2 relay strokes (each one POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write as objectstack-fleet[bot]): (1) pr_create => POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/25632/assignees [sunhaolin], read back byte-identical; (2) this os-dev-report comment => POST /repos/objectstack-ai/objectstack/issues/25609/comments. Zero label writes: the dispatch named no label, and skip-changeset does not apply (a changeset is present). git push is not a REST write.", "deviations": [ "The PM's mechanism assumption 'ledger row in flow.json, planned, authorHint' was measured false (check:liveness exit 1 on a planted row; restored and hash-verified). Shipped instead: a flow.json _note PLANNED RECORD plus the describe marker, per the page.json precedent. No authorWarn can reach this key's authors, because the liveness lint reads no deeper than a container's direct children.", "My first commit was written with the harness reminder's model-named Co-Authored-By trailer. I amended it to the AGENTS.md model-free pair BEFORE any push. No pushed commit carries a model identifier.", "The PR body footer uses the AGENTS.md session-URL footer form, not the harness reminder's emoji 'Generated with' line, per the role file's precedence clause.", "I ran gen:docs directly instead of `check:generated --fix`, because the spec dist was fresh from the preceding build and --fix rebuilds first. check:generated was then green.", "test:repo was not run locally (CI's Test Core runs it); the diff touches no repo-project test.", "No reverse-verification for a cross-package type change: the change only widens an optional key, and no consumer maps ApprovalNodeConfig keys exhaustively (git grep)." ], "files_changed": [ ".changeset/25609-approval-exclude-submitter.md", "content/docs/references/automation/approval.mdx", "packages/spec/authorable-surface/automation.json", "packages/spec/liveness/flow.json", "packages/spec/src/automation/approval-exclude-submitter-25609.test.ts", "packages/spec/src/automation/approval.zod.ts" ], "open_questions": [ { "question": "excludeSubmitter: true beside onEmptyApprovers: 'auto_approve'. The ruling sends an emptied set to the node's onEmptyApprovers route AND says 'never a silent pass', but auto_approve is the silent pass (its describe: 'silently waves the record through'). A submitter who is the only resolved approver would be auto-approved. Which shape resolves it? (For #25573 or the maintainer; no spec change was made here, and the describe states only the ruled invariant.)", "options": [ "A — the spec refuses excludeSubmitter: true with onEmptyApprovers: 'auto_approve' at parse, a refinement in the checkFallbackApproversPairing style. Loud, contract-first, a small spec PR. It is a narrowing once a release carries the key, so it is cheapest before then.", "B — the runtime (#25573) routes an exclusion-emptied slate to escalation (admin_rescue or ADR-0042) instead of auto-approving. No spec change, but a declared policy is then partly overridden at runtime, the 'quietly ignores half its config' shape this module's strictness comments warn against.", "C — accept auto_approve as authored: the route note on the approval record counts as 'not silent'. Lowest cost, but it reads against the ruling's segregation-of-duties intent." ], "recommendation": "A, because it makes the ruled 'never a silent pass' structurally true at the contract. It costs least before the key ships in a release, and it keeps the runtime free of a policy override. Route it to the maintainer as a scoped follow-up to ruling 6377731715 if the seat judges it beyond that ruling's letter." }, { "question": "Does the exclusion also screen the fallbackApprovers slate and the admin_rescue administrators when the submitter is among them? The ruling names only 'the resolved approver set'; the describe says 'the slate approvers resolves to'.", "options": [ "A — primary slate only (as the describe says today).", "B — every slate the node opens on (primary, fallback, admin rescue), with the record noting each exclusion." ], "recommendation": "B for segregation of duties. It is a runtime decision for #25573, which widens the describe when it lands." } ], "out_of_scope_findings": [ "carrier: none · noted, not filed — the liveness walk cannot reach ANY approval node config key (flow/nodes.config is z.record(z.string(), z.unknown())), so onEmptyApprovers, fallbackApprovers and the rest carry no ledger row either. Coverage observation, recorded in PR #25632's Measured section.", "carrier: none · noted, not filed — packages/spec/src/ui/component-record-blocks-empty-state-24266.test.ts's header says check-liveness MARKER_RE reads its [PLANNED] marker. By the same walk-boundary measurement (page/regions is a recorded undrilled container) no gate reads it. A comment inaccuracy only." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Landed ·
domain:specseat 2 (seat post #18549) ·sunhaolin· sessionsession_01PC4NPXPgPmpDRG9DdDnyXT· 2026-10-24T13:04Z49653f9181, committed 2026-10-24T12:16:40Z. ACCEPT6383784354; contract review PASS6383996601on head4823c57936gh-readonly-queue/main/pr-25632-*git ls-remote: 0 refs)origin/main49653f9181;packages/spec/src/automation/approval.zod.tsholdsexcludeSubmitter(1 hit)State: the card closed
completedon the merge;pm:dispatchedis removed in this stroke. #25573 (the runtime half) has its spec prerequisite; itsBlocked-by: #25635still holds. The key is unreleased. The next release carries it, and #25635's letter A is a free edit only before that release (contract review6383996601, ③ item 1).Generated by Claude Code