Skip to content

fix(sharing): enforce the declared publicSharing.eligibility predicate at link creation - #7974

Merged
huangyiirene merged 9 commits into
mainfrom
claude/issue-7861-share-link-eligibility
Aug 12, 2026
Merged

fix(sharing): enforce the declared publicSharing.eligibility predicate at link creation#7974
huangyiirene merged 9 commits into
mainfrom
claude/issue-7861-share-link-eligibility

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#7861

Route A per the triage ruling: implement the declared predicate. packages/spec/src/data/object.zod.ts is untouched — this restores what the key already declares.

The defect

publicSharing.eligibility was declared with a TSDoc promising the predicate is "evaluated against the candidate record when a link is created … the create call fails with 422", and nothing read it. getPolicy() built its policy from the five sibling keys and skipped this one, so createLink ran no predicate at all. Re-verified by symbol on origin/main at 8d80e12 before implementing — the premise still stood.

The harm is not at createLink. resolveToken has no auth check and reads the record under SYSTEM_CTX, so a link that gets minted is served to a caller with no principal. Any staff user who could read a record could publish it to the open internet past a policy written specifically to prevent that, with no error raised.

The fix

createLink now evaluates the predicate against the candidate record before the insert:

  • ineligible record ⇒ 422 RECORD_NOT_ELIGIBLE
  • predicate that will not compile, or faults on the record ⇒ 422 ELIGIBILITY_UNEVALUABLE

Both fail closed. A restrictive policy that cannot answer must not mint a capability token — the same direction recordStillExists already takes, and the same call the declarative hook-condition evaluator makes. The two codes stay distinct because the fixes differ: a false verdict is about this record, an unevaluable predicate is broken for every record until its author fixes it.

When an eligibility predicate is declared, the existing visibility read widens its projection to fetch the record it judges instead of a second query being issued — and it widens only then, so an object without the key keeps the exact id-only read it always had.

The evaluator choice, measured

The TSDoc promises "the same evaluator sharing rules use". A sharing rule's CEL condition is lowered by compileCelToFilter, which is the pushdown path. Measured on this branch:

compileCelToFilter("has(record.owner_id)") -> REJECTED (unsupported)
celEngine.evaluate ("has(record.owner_id)") -> true

The two answer different questions. A sharing rule asks which records does this rule share and must become a WHERE clause the driver can run, so its vocabulary is bounded by what backends push down. Eligibility asks may THIS record, already fetched, be published — a verdict on one record in hand.

This is not the fork the triage note reserved. Both are the same evaluator in the sense the contract means: @objectstack/formula's CEL through the one canonical front end (parseCelToAstWithReason) that cel-to-filter.ts was moved onto in #6132, with cel-to-filter-parse-convergence.test.ts pinning that both parse alike. Same dialect, same parse, different lowering — so the declared contract text needed no change, and the key's own .describe() ("must evaluate to true on the target record") is honoured exactly. No spec contract text was edited.

Declared fields absent from the returned row bind to null before evaluation, mirroring materializeDeclaredFields behind the two server-side CEL gates in @objectstack/objectql. Without it, drivers that omit NULL columns would fault — and since this gate fails closed, that would refuse links for exactly the rows whose field is empty rather than judging them. An undeclared key still faults, because that one really is an author typo.

Tests

New share-link-eligibility.test.ts, 14 cases, driving the real ShareLinkService over a live SqlDriver on better-sqlite3 :memory: with DDL through the driver's own initObjects from the real SysShareLink definition.

Evidence is end-to-end and about the row, not the throw: every rejection case asserts codeandstatus (the ADR-0112 envelope) and that sys_share_link took no row; the headline case walks mint → resolve on both an eligible and an ineligible record. A bare toThrow() would have passed against the pre-existing 403/404 visibility refusal.

The card's sibling table is pinned beside it as the control group — allowedAudiences and maxExpiryDays had no pin in this package before.

Predict-then-mutate ablation

Predictions were written down before the mutation ran, then the assertEligible call was deleted from createLink (enforcement only; the projection widening left in place, isolating enforcement as the variable).

Predicted 7 red / 7 green. Observed exactly that, on exactly the predicted cases: all seven eligibility cases flipped red; all five sibling-key pins stayed green, as did "no eligibility key behaves exactly as before" and the deliberate non-flipper (has(record.owner_id) still mints). Restored from the commit and re-verified green.

Gates run locally

@objectstack/plugin-sharing test (468 passed / 18 files) and typecheck; @objectstack/spec test (10031 passed / 381 files); check:nul-bytes; check:error-code-casing; check:engine-double-contract (caught this PR's harness and it was pinned to assertEngineUpdateDispatch); pnpm --filter @objectstack/spec check:generated — all 13 artifacts up to date after regenerating content/docs/references/ for the two new ledger codes; eslint --no-inline-config on the changed files; full workspace build (71/71); and the downstream consumer sweep, --filter '...@objectstack/plugin-sharing' (packages that depend on it), typecheck with zero errors.

Note on the spec edit

packages/spec/src/api/error-code-ledger.zod.ts gains the two codes under @objectstack/plugin-sharing. ApiErrorSchema.code validates against StandardErrorCode union the ledger, so an unregistered code would itself be a declared-but-invalid surface — the same class this card is about. This is the error-code registration surface, not the object.zod.ts contract text the ruling placed out of scope.


Generated by Claude Code

Evaluate the declared CEL predicate against the candidate record before the
share-link insert and refuse with 422, so an ineligible record can no longer
mint a token that resolveToken serves anonymously.
Refs #7861
…ve siblings
Drives the real ShareLinkService over a live SqlDriver (better-sqlite3
:memory:), asserting code+status on every refusal and that sys_share_link
takes no row — the harm is the minted token, not the throw.
Refs #7861
Adds RECORD_NOT_ELIGIBLE and ELIGIBILITY_UNEVALUABLE to the ADR-0112 ledger
under @objectstack/plugin-sharing, with the generated reference docs.
Refs #7861
…patch predicate
check:engine-double-contract — a double looser than ObjectQL.update is how a
dead route once shipped with a green suite.
Refs #7861
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 12:51pm

Request Review

@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

106 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/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 @objectstack/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/tenancy-modes.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 @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/plugin-sharing, @objectstack/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 @objectstack/plugin-sharing, @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-sharing, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/spec)
  • content/docs/permissions/authorization.mdx(via packages/plugins/plugin-sharing, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via packages/plugins/plugin-sharing, @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/permissions/system-context.mdx(via packages/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-sharing, @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/http-protocol.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/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/plugins/plugin-sharing, 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/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.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/field-grouping-and-order.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)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-sharing, @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/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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.

…erQuery
check:query-options-erasure — the `{} as any` on the sys_share_link sweep
erased the driver-side query type. Typed local instead; ratchet back to 242.
Refs #7861
check:test-source-alias — the eligibility suite drives a real SqlDriver, and
without an alias that import resolved to driver-sql's dist, making a security
pin a verdict about build state rather than the source in the checkout.
Anchored array form so no subpath resolves through a file extension. The
package's registry entry is unchanged.
Refs #7861
The 11 content/docs/references/api/*.mdx artifacts are merge=os-regen paths;
the driver defers them and pre-commit collects the debt. Regenerated from the
merged tree (gen:schema then gen:docs) rather than text-merged, so the result
is the union: main's TENANT_SCOPE_REQUIRED alongside this branch's
RECORD_NOT_ELIGIBLE and ELIGIBILITY_UNEVALUABLE.
Refs #7861
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 13:40
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 6e6c872Aug 12, 2026
28 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7861-share-link-eligibility branch August 12, 2026 13:59
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(sharing): publicSharing.eligibility is declared-but-unenforced — a draft or internal record mints a public share link and is served anonymously

2 participants

@huangyiirene@claude