Skip to content

fix(spec,plugin-approvals): the two approval vocabularies are derived, not hand-matched (#3786) - #4295

Merged
os-zhuang merged 2 commits into
mainfrom
claude/spec-checklist-drift-investigation-ewgd6l
Jul 31, 2026
Merged

fix(spec,plugin-approvals): the two approval vocabularies are derived, not hand-matched (#3786)#4295
os-zhuang merged 2 commits into
mainfrom
claude/spec-checklist-drift-investigation-ewgd6l

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The last known derivable copy from the #3786 sweep. (A fourth, non-derivable copy turned up while verifying — see the end.)

What it was

sys_approval_request.status and sys_approval_action.action spelled their option lists out — five values and twelve — each under a "Keep in sync with ApprovalStatus / ApprovalActionKind (spec/contracts)" comment, while the contract held the same sets as bare type unions. Seventeen strings matched by hand across a package boundary, with nothing checking them.

They did all still agree — that was verified verbatim before anything changed. But agreeing is not being held, and both directions of drift are quiet:

  • a value the column accepts and the contract omits is invisible to every consumer typed against the contract — the row exists and nothing can narrow it;
  • a value the contract declares and the column rejects surfaces only at write time, on whichever tenant first reaches that transition.

An audit vocabulary is a bad place for either: it is precisely what gets read back when someone asks what happened.

The fix

The contract publishes the lists as valuesAPPROVAL_STATUSES and APPROVAL_ACTION_KINDS — with ApprovalStatus / ApprovalActionKind derived from them via (typeof X)[number]. Both columns spread the constants. The per-entry rationale (which action kinds move the flow, which are thread-only, why returned differs from recalled) moved onto the constants, where the values live.

No behaviour change. The emitted option lists are byte-identical — checked against the built artifact before and after:

status  : ["pending","approved","rejected","recalled","returned"]
action  : ["submit","approve","reject","recall","escalate","reassign",
           "remind","request_info","comment","revise","resubmit","ooo_substitute"]

Existing imports of the two types are unaffected — they resolve to the same unions. Two new exports, 0 breaking on check:api-surface.

The test pins what derivation cannot

Derivation makes disagreement unrepresentable while the spread is there. approval-vocabularies.test.ts pins that qualifier: it fails if either column is re-inlined as a literal that has drifted. It cannot catch an identical re-inline — correct today, fragile tomorrow — which is why the derivation, not the test, is the mechanism.

It also guards the guard (an unresolvable import would compare two empty lists and pass), and asserts the two vocabularies stay distinct — a copy-paste pointing one column at the other constant would satisfy "derived from the contract" while being the wrong vocabulary entirely.

Verification

Mutation-tested in both directions, which matters here because the two failure modes are different:

mutation result
add a value to APPROVAL_STATUSES, rebuild both packages it appears in the built sys_approval_request.status options, and action is untouched — the derivation is live, not a stale build
re-inline status as a drifted literal sys_approval_request.status offers exactly the contract statuses, in order fails
  • plugin-approvals — 330 tests green (13 files)
  • spec — 7169 tests green (276 files)
  • check:api-surface (regenerated: 0 breaking, 2 added) / check:generated / check:exported-any / check:liveness / check:docs — all PASS

A fourth copy, found by the docs-drift check

content/docs/automation/approvals.mdx carries a "Statuses in full: …" line — the same five values, in the highest-visibility place. Unlike the CORS case in #4282, this one is correct today, so there is nothing to fix; it is simply unguarded.

The last commit points it at APPROVAL_STATUSES and says plainly that it is a copy nothing updates automatically. That is weaker than derivation and this PR does not pretend otherwise: prose docs legitimately restate values, and a test asserting that exact sentence would be brittle against any doc restructure. check:variant-docs already solves this shape for schema variants and is the right place to extend if the set starts moving — recorded as a follow-up rather than bolted on here.

Note

pnpm --filter @objectstack/plugin-approvals exec tsc --noEmit reports a pre-existing replaceAll lib-target error in action-link-pages.ts — present on a clean tree, and that package has no typecheck script (bare tsc isn't its build path). Its real build (tsup) is clean.

…, not hand-matched (#3786)

`sys_approval_request.status` and `sys_approval_action.action` spelled their
option lists out — five values and twelve — each under a "Keep in sync with
`ApprovalStatus` / `ApprovalActionKind` (spec/contracts)" comment, while the
contract held the same sets as bare type unions. Seventeen strings matched by
hand across a package boundary with nothing checking them.

They did all still agree — verified verbatim before changing anything. But
agreeing is not being held, and both directions of drift are quiet: a value the
COLUMN accepts and the contract omits is invisible to every consumer typed
against the contract (the row exists and nothing can narrow it), while a value
the CONTRACT declares and the column rejects surfaces only at write time, on
whichever tenant first reaches that transition. An audit vocabulary is a bad
place for either.

The contract now publishes the lists as values — APPROVAL_STATUSES and
APPROVAL_ACTION_KINDS — with ApprovalStatus / ApprovalActionKind derived via
`(typeof X)[number]`, and both columns spread the constants. The per-entry
rationale (which action kinds move the flow, which are thread-only, why
`returned` differs from `recalled`) moved onto the constants, where the values
live.

New exports, no behaviour change: the emitted option lists are byte-identical,
checked against the built artifact before and after. Existing imports of the two
types are unaffected — they resolve to the same unions.

approval-vocabularies.test.ts pins the qualifier derivation alone cannot: the
columns agree with the contract WHILE THE SPREAD IS THERE, so the test fails if
either is re-inlined as a literal that drifted. It guards the guard (an
unresolvable import would compare two empty lists and pass) and asserts the two
vocabularies stay distinct — a copy-paste pointing one column at the other
constant would satisfy "derived from the contract" while being the wrong
vocabulary entirely.

Mutation-tested in both directions: adding a value to APPROVAL_STATUSES
propagates into the BUILT sys_approval_request.status options (the derivation is
live, not a stale build), and re-inlining a drifted literal fails
`sys_approval_request.status offers exactly the contract statuses, in order`.

Verified: plugin-approvals 330 tests green, spec 7169 green, api-surface
regenerated (0 breaking, 2 added), check:generated / exported-any / liveness /
docs all PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 31, 2026 4:08am

Request Review

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-approvals, @objectstack/spec.

107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 31, 2026
The docs-drift check turned up a FOURTH copy of the status set, in the highest-
visibility place: `automation/approvals.mdx`'s "Statuses in full" line. Unlike
the CORS case in #4282 this one is correct today — all five values, same order —
so there is nothing to fix, only something unguarded.

Points at APPROVAL_STATUSES and says plainly that this line is a copy nothing
updates automatically. That is weaker than derivation and I am not pretending
otherwise: prose docs legitimately restate values, and a path-coupled test
asserting this exact sentence would be brittle. Recorded as a follow-up instead —
`check:variant-docs` already solves this shape for schema variants and is the
right place to extend if the set starts moving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb
@os-zhuang
os-zhuang marked this pull request as ready for review July 31, 2026 04:24
@os-zhuang
os-zhuang merged commit 820eff9 into main Jul 31, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/spec-checklist-drift-investigation-ewgd6l branch July 31, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant