Skip to content

chore(spec): classify what a restriction-shaped property's EMPTY value means (#3896 follow-up) - #3945

Merged
os-zhuang merged 2 commits into
mainfrom
claude/sharing-rules-schema-bypass-h4c7xr
Jul 29, 2026
Merged

chore(spec): classify what a restriction-shaped property's EMPTY value means (#3896 follow-up)#3945
os-zhuang merged 2 commits into
mainfrom
claude/sharing-rules-schema-bypass-h4c7xr

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Follow-up to #3929 (merged). That PR fixed one field; this one governs the class.

The generalisable part of #3896

sys_sharing_rule.criteria_json was optional, and its absence evaluated to find(object, { filter: {} }) — every record of the object, granted to the recipient. The field description said so out loud: "leave empty to share every record."

That sentence is what's worth generalising. For a platform whose premise is that agents author metadata, a field description is not documentation about the contract, it is the contract the next author reads. And omission is the commonest error a model makes, precisely because it raises none — nothing is missing from the artifact, the mistake is in what was never written. When omission also lands on the widest grant, the likeliest error is the most dangerous outcome, silently.

What the sweep found

The same syntactic shape — an optional list or predicate that "restricts" something — carries opposite meanings when empty, with nothing marking which is which:

Property Empty means
object.apiMethods undefined = unrestricted, [] = deny-all
plugin-runtime.allowedSources "empty = all allowed"
sharing condition nothing is shared (#3929)

A maintainer knows by memory. A model authoring metadata cannot, and guessing wrong is silent and permissive.

The gate

pnpm --filter @objectstack/spec check:empty-state, wired as a second step into the existing Spec Liveness Check workflow (it already triggers on packages/spec/**).

It scans packages/spec/src/**/*.zod.ts for statements declaring an empty state to be permissive, and requires each to be classified in scripts/liveness/empty-state-registry.mts:

semantics Meaning
scope Selects a range of work (which objects to replicate, which events to replay). Empty = all is correct, often the safe direction. Not an access decision.
closed An access gate whose empty state DENIES — the required posture for new gates. Has no permissive prose to scan, so it is exempt from the staleness check and exists as the catalogue answer.
open An access gate default-open on purpose — legitimate, but mandatory rationale, since it is the shape that produced #3896.
output Not authorable: a computed projection (an explain trace, a server-resolved effective set). Its prose describes a result, not a policy.

closed / open must cite where the posture is enforced; the path resolves against the checkout, so a rotted pointer is reported rather than trusted. 20 statements across 214 schema files are now classified (1 closed, 2 open, 4 output, 9 scope); adding an unclassified one fails CI.

Why the liveness ledger's machinery and not a new lint rule. The ledger asks "does this property do anything?"criteria_json passed that test at every layer. This asks the question it doesn't: what does its empty value mean? Same ratchet philosophy, same evidence resolution, same "no new undeclared surface" contract, one class at a time (ADR-0054 §3).

Why prose and not field names. Names would be a guess; the statement is exact. The liveness README is blunt about where a guessy check ends up — "a permanently-noisy check is a check nobody reads" — so the detector:

  • ignores negated tokens, or the ⚠ object.zod.ts prints precisely to warn that an empty whitelist is DENY-ALL would be flagged as if it were permissive, inverting the gate's meaning;
  • requires a permissive consequence after "leave empty", so a bare deprecation note (metadata-persistence.environmentId: "new writes leave unset") stays quiet;
  • does not treat : as a separator — every schema line has one;
  • resolves the owning property bidirectionally, because a JSDoc block sits above its property while a .describe() argument sits below it (searching one way mis-attributed explain.zod.ts's statements to the next property down the file);
  • reports statements that resolve to no property as non-failing notes — a file header explaining a past bug is narrative, not a claim about any field's contract.

One behavioural correction

DynamicLoadingConfig.allowedSources — a supply-chain gate — documented [] as admitting every source. It now states the apiMethods three-state: undefined = any source, [] = deny-all, a subset = exactly those types.

The empty ARRAY is closed; only ABSENCE is open. Collapsing the two is what makes an allow-list vacuous — the one value an author reaches by mistake is also the widest grant.

The field has no runtime consumer: the whole DynamicLoadingConfig block (requireIntegrity, defaultSandbox, allowedSources) is declared and unenforced — the ADR-0049 false-compliance shape — and is not addressed here. That absence is exactly why the wording mattered: an unimplemented property's description is the specification whoever implements it will build to. It now carries an [EXPERIMENTAL — not enforced] marker so authors are not misled meanwhile.

Also

Registers the sharing-rule-criteria-required dogfood proof added by #3929, which was declaring a @proof: tag proof-registry.mts did not know about — the liveness gate had started warning about the orphan. Unbound, for the same reason as showcase-bu-hierarchy-sharing: sharing rules are authored at stack level, so there is no governed per-type ledger entry to ratchet.

Verification

Not in this PR

  • The unenforced DynamicLoadingConfig block (above) — a real ADR-0049 enforce-or-remove item, security-shaped, deserving its own change.
  • knowledge-source.mimeTypes is registered scope but is the vacuous-allow-list shape. storage.zod.ts already models the identical concept the better way — an explicit mode: 'whitelist' discriminator plus .min(1), so an empty whitelist cannot be expressed at all. Preferred for new allow-lists; changing this one is a breaking change to an authorable field.
  • Decoupling required (write-time) from NOT NULL (storage), which is what forced fix(sharing)!: a rule with no criteria shares NOTHING, not every record (#3896) #3929's invariant into imperative write guards rather than a declarative required: true. Still worth doing, but it is ergonomics — this PR covers the safety boundary.

Generated by Claude Code

…e means (#3896)

#3929 fixed one field. `sys_sharing_rule.criteria_json` was optional, and its
absence evaluated to `find(object, { filter: {} })` — every record of the
object, granted to the recipient. Its description said so out loud: "leave empty
to share every record."

That sentence is the part worth generalising. For a platform whose premise is
that agents author metadata, a field description is not documentation about the
contract, it IS the contract the next author reads — and omission is the
commonest error a model makes, precisely because it raises none. When omission
also lands on the widest grant, the likeliest mistake is the most dangerous
outcome, silently.

Sweeping the spec found the same syntactic shape — an optional list or predicate
that "restricts" something — carrying opposite meanings when empty:

  object.apiMethods       undefined = unrestricted, [] = deny-all
  allowedSources          "empty = all allowed"
  sharing condition       nothing is shared (#3929)

Nothing marked which was which. A maintainer knows by memory; a model cannot.

New gate `check:empty-state`, wired into the Spec Liveness Check workflow: it
scans the schema surface for statements declaring an empty state permissive and
requires each to be classified in empty-state-registry.mts as `scope`, `closed`,
`open` or `output`. Access-gate classifications must cite where the posture is
enforced, resolved against the checkout so a rotted pointer is reported. 20
statements across 214 schema files are classified; a new unclassified one fails.

Detection matches the STATEMENT, not field names — names would be a guess, and
the liveness README is blunt about where that ends up ("a permanently-noisy
check is a check nobody reads"). Negated tokens are ignored, so the ⚠ that
object.zod.ts prints to warn an empty whitelist is DENY-ALL is not itself
flagged as permissive. Statements resolving to no property are narrative and
reported as non-failing notes.

One correction: `DynamicLoadingConfig.allowedSources`, a supply-chain gate,
documented [] as admitting every source. It now states the apiMethods
three-state — undefined = any source, [] = deny-all, a subset = those types. The
empty ARRAY is closed; only ABSENCE is open. Collapsing the two is what makes an
allow-list vacuous. The field has no runtime consumer (the whole block is
declared-but-unenforced, ADR-0049 false compliance, not addressed here), which
is why the wording mattered: an unimplemented property's description is the spec
whoever implements it builds to. It now carries an [EXPERIMENTAL] marker.

Also registers the sharing-rule-criteria-required dogfood proof added by #3929,
which had been declaring a @proof: tag the registry did not know about.

No runtime behaviour changes. spec: 263 files / 6858 tests green, tsc clean.

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

vercel Bot commented Jul 29, 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 29, 2026 12:01pm

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file tests tooling labels Jul 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec.

105 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 packages/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/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/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/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/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.

…urces contract

The `check:docs` step of the "TypeScript Type Check" job regenerates
content/docs/references/ from the spec and diffs it. Correcting the
`allowedSources` describe text left the generated page stale.

The regenerated line is the point of the change, not a side effect: the
reference table now states the three-state contract — undefined = any source,
[] = deny-all, a subset = exactly those types — where an author reads it,
instead of "Restrict which source types are permitted", which said nothing
about what emptiness does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX
@os-zhuang
os-zhuang marked this pull request as ready for review July 29, 2026 12:22
@os-zhuang
os-zhuang merged commit 1d22114 into main Jul 29, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/sharing-rules-schema-bypass-h4c7xr branch July 29, 2026 12:22
os-zhuang added a commit that referenced this pull request Jul 29, 2026
…config that never existed (#3950)

Follow-up to #3896/#3945. Found while building the empty-state gate: `allowedSources` documented `[]` as admitting every source, and checking who enforced that turned up nobody.

`DynamicLoadingConfigSchema`, `PluginDiscoveryConfigSchema` and `PluginDiscoverySourceSchema` promised sandboxing (`defaultSandbox`), integrity verification (`requireIntegrity`), source allow-listing (`allowedSources`) and admin approval (`discovery.requireApproval`) for dynamically loaded plugins. None of it was ever wired to anything — the three schemas were an island, referenced only by their own round-trip tests, yet published into json-schema/ and the authorable key surface where an author reads them as capabilities the platform has.

That is ADR-0049 false compliance in its security shape, and the precedent (`tool.requiresConfirmation`, #3715) is to remove rather than mark dead. No tombstones: nothing ever parsed these schemas, so a retiredKey() prescription could never be delivered — the manifest and authorable-surface baselines are dropped as the deliberate removal both files document, with a block comment in the source recording the reasoning.

Breaking in the narrow sense: the types were importable via the published ./kernel subpath. Nothing imported them; folds into the unreleased 17.0.0. `ActivationEventSchema` (7 real consumers) stays.

spec: 263 files / 6856 tests green, tsc clean, all six spec gates green, reference docs regenerated.
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…ates (#3966)

Follow-up to #3896/#3945. That gate required any "empty = permissive" statement in the spec to be classified on purpose — and its scan scope had a hole big enough to miss the bug it was built for. The sentence that shipped #3896, "leave empty to share every record", was the description of sys_sharing_rule.criteria_json, which lives in plugin-sharing. The gate could not see its own crime scene.

Now scans **/*.object.ts anywhere under packages/ — plugins, platform-objects, metadata-core, and the create-objectstack templates, since a starter file is the highest-leverage thing a model copies from. 214 → 290 files.

It immediately found a real one. sys_user_permission_set.organization_id declares "NULL = applies in every org context": a user↔permission-set grant with no org scope applies everywhere. Deliberate and load-bearing rather than an oversight — ADR-0095 D3 / ADR-0068 D2 derive the platform_admin posture from an UNSCOPED admin_full_access grant specifically, and an org-scoped grant of the same set must not confer it. The empty state is not merely wider; it is the distinguishing input to the highest privilege in the system. Registered `open` with that rationale and both enforcement sites cited.

Three fixes the new surface forced: repudiated prose no longer fires (#3929's own "Deliberately NOT ..." comment was being flagged, with the escape kept narrow so a genuine claim carrying an unrelated negation is still caught); the owning property is resolved by nesting rather than a name list (skipping `description` answered `required`, the sibling above it — indentation is what separates a field from its config); and the property-search window is per-surface, since widening it globally would mis-attribute .zod.ts narrative to a distant property.

Evidence is now parsed with the liveness ledger's own checkEvidence, so prose and multiple paths work — the README already promised that, and a raw-path existsSync quietly did not.

6 new unit tests (32 total). spec: 263 files / 6862 tests green, tsc clean, both gates green. No runtime behaviour changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants