Skip to content

fix(services): settings, datasource, webhook and messaging plumbing passes the explicit system opt-in instead of no principal - #21940

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21913-principal-less-producers-services
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21913-principal-less-producers-services

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21913
Clause-②: yes (widening)

This is a slice of #21908: the services-lane producers. #21908 stays open, because it builds the deny itself, last.

What changes

Every engine call in the card's named functions now passes the explicit system opt-in that exists today: { isSystem: true } on the call's context. These calls used to reach the data engine with no context at all, so they had no principal and no opt-in. They got past the security middleware only through its principal-less hand-off (ADR-0096 E1), which #21908 retires. This PR adds no new elevation API, changes nothing any door authorizes, and does not build the deny.

Row Package Function Engine calls that now carry the opt-in
7 service-settings SettingsService.loadRows find on sys_setting
8 service-settings SettingsService.upsertRow existence-probe find and insert on sys_setting (its update already had the opt-in)
8 service-settings buildSettingAuditWriter write insert on sys_setting_audit
11 service-datasource loadDatasourceRows, loadDatasourceRow find / findOne on sys_metadata
11 service-datasource persistDatasourceRow, deleteDatasourceRow findOne + insert / update / delete on sys_metadata
11 service-datasource secret binder bind / unbind / resolve insert / delete / find on sys_secret
12 plugin-webhooks AutoEnqueuer.doRefresh find on sys_webhook
12 plugin-webhooks createWebhookRedeliverGuard findOne on sys_webhook
13 service-messaging SqlNotificationOutbox.claim / claimDigest / reapExpired candidate find, claiming update, read-back find; the reap update
13 service-messaging SqlHttpOutbox.claim / reapExpired candidate find, claiming update, read-back find; the reap update
14 service-messaging MessagingService.writeEvent insert on sys_notification
14 service-messaging inbox channel send + writeDeliveredReceipt insert on sys_inbox_message, the recipient-locale findOne on sys_user (a helper only send calls), insert on sys_notification_receipt
14 service-messaging PreferenceResolver.loadRows both finds on sys_notification_preference
14 service-messaging RecipientResolver.resolveEmail findOne on sys_user

IDataEngine reads pass the opt-in in the trailing options argument, which is where the contract puts a read's context. Two package-local surfaces have a single options bag, and the opt-in goes there: SettingsEngine, and the sys_secret binder's engine slice. SettingsEngine.find and .insert and SecretStoreEngineLike.delete now declare the context they receive. No symbol is new on any package entry. The shared constants (FAN_OUT_SYSTEM_CONTEXT, DISPATCHER_SYSTEM_CONTEXT) live in package-internal modules.

Rows 15 and 16 are not in this slice and wait for the maintainer.

Measurement

Instrument (H2). A local, uncommitted instrument sat at the security middleware. It recorded each principal-less, non-system context that reached the hand-off, with its stack. It recorded whether any of the six gates before the hand-off threw on such a call, and which of them matched the call's object and verb. It also recorded the outcome after next(): the result type, row count, key set, a hash of the non-volatile values, or the error code. In the AFTER leg it recorded the same outcome for each isSystem call whose stack ran through these four packages. Both legs covered the whole dogfood suite (206 files, 1590 tests passed, 9 skipped, identical in both legs) and a booted showcase dev composition. The boot covered seed-admin, a settings read plus two writes, a runtime datasource create / patch / read / delete, and admin and anonymous requests, then sat idle for 65 seconds so the dispatchers and the webhook refresh ticked. The instrument was then reverted, and the file's blob equals HEAD (5b4ab28045af). The plugin-security dist was rebuilt clean: ablation-dist-preflight --absent passes, and the marker had 3 hits in the instrumented dist.

Before and after, per function. Columns: principal-less records BEFORE, principal-less records AFTER, and isSystem records AFTER.

Function dogfood before / after / after-system boot before / after / after-system
SettingsService.loadRows 2090 / 0 / 2090 42 / 0 / 42
SettingsService.upsertRow (probe + insert) 7 / 0 / 7 3 / 0 / 3
setting-audit write 4 / 0 / 4 2 / 0 / 2
loadDatasourceRows — 1 / 0 / 1
persistDatasourceRow — 4 / 0 / 4
deleteDatasourceRow — 2 / 0 / 2
AutoEnqueuer.doRefresh — 3 / 0 / 3
SqlNotificationOutbox.claim 8 / 0 / 8 56 / 0 / 56
SqlNotificationOutbox.claimDigest 8 / 0 / 8 56 / 0 / 56
SqlNotificationOutbox.reapExpired 1 / 0 / 1 7 / 0 / 7
SqlHttpOutbox.claim 8 / 0 / 8 56 / 0 / 56
SqlHttpOutbox.reapExpired 1 / 0 / 1 7 / 0 / 7
MessagingService.writeEvent 8 / 0 / 8 —
inbox send (row insert) 8 / 0 / 8 —
writeDeliveredReceipt 8 / 0 / 8 —
PreferenceResolver.loadRows 16 / 0 / 16 —
RecipientResolver.resolveEmail 1 / 0 / 1 —

Principal-less totals moved 35245 → 33077 in dogfood (Δ 2168) and 422 → 183 at boot (Δ 239). Each delta is exactly the sum of the rows above. No principal-less record attributed to any moved function remains. The hand-off still sees row 15 and every other lane's producers.

No run reached these, so each is held by its unit pin instead: loadDatasourceRow, the secret binder (this repo wires it into no composition), the redeliver guard, the inbox recipient-locale read (template path), and the claim path's update and read-back (no pending rows in any run).

Gates before the hand-off (Zone 1). Across 35245 dogfood and 422 boot principal-less records, the "gate threw" record fired 0 times. The package-managed, system-row, curated-capability and audience-anchor gates never matched an object or verb these producers touch. Neither did the delegated-administration gate. The engine-owned guard matched the bucket on the writes to engine-owned objects. On a context with no user id, its own isUserContextWrite predicate returns before it can refuse. No producer is held back.

What each call answers is unchanged. Per function, call counts per object and verb are equal before and after. So are the outcome shapes (result type, row count, key set). There were 0 errors in either leg. Content hashes are equal for 11 of 14 functions in dogfood and 7 of 9 at boot. The rest differ only on values that change every run: the receipt's at timestamp (all 8), and inbox and notification payloads that carry a per-run record id or date (2 of 8 and 3 of 8, from the approval and sweep tests). At boot, the probe's own per-phase file path sits in the stored datasource record. The plugin-audit rows these writes produce (sys_audit_log, sys_activity) are written in equal numbers before and after.

H6, loadRows. The call count is the same (2090 + 42), and the returned settings have equal hashes on every call. The opt-in adds one frozen context object. The middleware now exits at its system short-circuit instead of running the six gates and the hand-off. No wall-clock figure is quoted, because the container is shared.

H7, reads on another principal's behalf. What these reads return (a user id for an address, a locale, preference rows) is consumed inside the fan-out. emit() answers the notification id, counts and per-delivery outcomes. Its three in-repo callers (approvals, the flow notify node and comment mentions) relay counts and the id only. The opt-in changes none of this, because the principal-less read returned the same rows.

One engine branch keyed on the flag stops running on these writes. It is row 23 of the isSystem census page: the dangling-reference check is skipped for an isSystem write. Before the move, it ran 10 times nested under these producers (writeEvent 2, inbox send 2, setting-audit write 6), on the actor_id lookups, and resolved every time. After the move it does not run. A local probe (real ObjectQL and SQLite, deleted after the run) showed what that means for an actor_id that names no user. With no context, today's path refuses with VALIDATION_FAILED ("Actor: no sys_user record has id …"). Under isSystem the row is written. A real user is written both ways. That actor_id comes from emit()'s actorId, which a flow notify node can author. So the behaviour on measured traffic is unchanged, and a latent difference remains for an actorId that names no user. The Acceptance notes carry it.

H4 pins and ablations. There is one pin per package. The engine double sits behind the package's real call path, proves the population ran, and asserts isSystem on every call. Each pin was ablated by dropping the opt-in through scripts/ablation-replace.mjs (the anchor must hit). Seven legs ran: settings loadRows, the fan-out constant, the dispatcher constant, the datasource sys_metadata constant, the secret-binder constant, and the two webhook constants. Every leg went red under the mutation, and the failure names the call, for example "find on sys_setting: expected undefined to deeply equal { isSystem: true }". Every leg was restored with blob equal to HEAD and an empty git diff HEAD, and went green again. The pins are package-local, imported from src with no dist in the path.

Census pages (H3). The isSystem census (check-system-context-census) is OK, and --fix changed nothing: this change adds no elevation read site. The tenant-audit census did move, because the write sites now thread a context. It was regenerated with tenant-audit-census.mjs --write. On its page, the hand-written figures follow the census: the provable no-context, tenancy-enabled count went 9 → 2, unreadable 67 → 60, decidably elevated 114 → 121.

Serial (H5). origin/main was merged twice. It now includes #21906's squash, and the merge was clean. A git merge-tree against #21877's head (5c405846, now closed as a draft) is clean. This PR edits neither PR's region: datasource-admin-plugin.ts and datasource-secret-binder.ts only, in service-datasource.

Tests

  • At 10e77fef6f, after merging origin/main faf8dce482. The next merge (9dce635337, which brings this PR to 62960ffa1a) touches no file in these four packages. Typecheck of the four packages: exit 0.
  • Unit suites: service-settings 614 passed, service-messaging 510, service-datasource 743, plugin-webhooks 165. All exit 0, unchanged apart from the new pins and tests that came in from main.
  • ESLint, narrowed to the 19 changed TS files with --no-inline-config --format json: 19 files, 0 errors, 0 warnings. Those files are inside the config's own packages/**/*.{ts,…} population, and the config enables no type-aware linting, so this diff cannot move a verdict on any untouched file. The full pnpm lint run belongs to CI.
  • At 62960ffa1a, the head this PR opens with, every one of the 105 commands dispatch-gates --commands --repo objectstack-ai/objectstack derives exited 0. dispatch-gates --ran reports: "105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED". In an earlier pass, four of these went red on this branch, and they are now fixed. check:tenant-audit-census needed the census regenerated. check:engine-double-contract and check:objectql-double-limit needed the pin doubles routed through the shared dispatch asserts and holding a find's bound, with the ledger recording the new pinned coverage. check:dual-build-cjs-loads needed eight unrelated packages built first.

Acceptance notes

  • Producers in these packages that the card does not name. A static read finds that they still reach the engine with no context. No run exercised them, so the measured table never listed them. Without a route, security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908's deny breaks each one, so they are listed for the seat's closure rather than moved here:
    • service-settings: the sys_secret store the plugin builds (insert / get / update), and SettingsService.readStoredHandle.
    • service-messaging: SqlNotificationOutbox and SqlHttpOutbox enqueue, ack and list; the email and SMS channels' recipient reads; RecipientResolver.resolveRole / resolveTeam / resolveOwnerOf; the emit dedup lookup; the template renderer's read.
    • resolveOwnerOf reads a business object, and its posture is not neutral. Today the sharing middleware answers a principal-less read of a private object with a deny-all filter, so an owner_of: recipient on such an object resolves to nobody. Under the opt-in, that filter would be bypassed.
  • Request-door producers that act on the caller's own rows, like rows 15 and 16 (report-only, for the maintainer's ruling): the inbox unread count, and mark-read / mark-all-read (unreadNotificationIds, upsertReadReceipt, notificationOrganization).
  • The row-23 difference above: the dangling-reference check stops running on the actor_id of sys_notification, sys_inbox_message and sys_setting_audit.
  • One posture question was noted on a request-door read and is held off-thread. It was not measured.

Seat's append: patch round 1 at 57f738dfb1 (written by domain:services seat 1 from the dev's report 6009307655; the dev does not edit this body)

What changed in the patch round (seat verdict 6008259054). The sections above describe 62960ffa1a; where they differ, this append is current.

  • Clause-②: yes (widening). The exported SettingsEngine (find, insert) and SecretStoreEngineLike (delete) gain an optional context, so @objectstack/service-settings and @objectstack/service-datasource take a minor. service-messaging and plugin-webhooks stay patch. Line 2 above, the changeset and the claim (6003840075) moved together. Nothing accepted or refused at any door changes.
  • A user reference that names no user is still refused. The engine skips its dangling-reference check for an isSystem write and has no option to keep it. So each producer that writes a user reference does one guarded sys_user read by id under the opt-in, then refuses an unknown id with the engine's own answer: VALIDATION_FAILED, one reference_not_found finding, and the same message.
    • The checked references are the actor_id of sys_notification (writeEvent), of sys_inbox_message (the inbox send) and of sys_setting_audit (the setting-audit writer), and the user_id of a user-scope sys_setting row on SettingsService.upsertRow's insert. The last is the same difference, which this PR's opt-in introduced on that insert.
    • The refusal is built by validationFailure from @objectstack/types, already a runtime dependency of both packages, so neither package stamps the code itself and check:error-code-provenance is green with no spec row and no waiver. It is shape-identical to the engine's refusal but not instanceof objectql's ValidationError; the callers on these paths read the message or the code, and every door maps the shape to 400 VALIDATION_FAILED.
    • A write that names no user is unchanged. A read that cannot run lets the write through, as the engine's check does. The cost is one extra sys_user read per write that names a user.
    • Differential pins over a real engine hold each producer's answer equal to the engine's own refusal of a context-less insert. Four ablations went red and were restored with blob equal to HEAD.
  • test(dogfood): each file's temporary cwd is created from a base the scratch-dir scan can read #21935 merged in (a3c2209a68). check:pm-dispatch-gates exits 0.
  • Gates at 57f738dfb1: 105 derived, 105 run, all exit 0. The 54 roster families: 51 exit 0, and 3 are NOT WIRED locally (they need a pull-request context; CI runs them).
  • service-settings/vitest.config.ts gains one anchored alias (platform-objects/identity → src) for the new pin, which check:test-source-alias asks for.

Carried, not filed here:


Generated by Claude Code

claude added 7 commits October 5, 2026 23:19
…stead of no principal

The services-lane producers that reached the data engine with no
principal and no isSystem now pass the explicit system opt-in
({ isSystem: true }) on every engine call:

- service-settings: SettingsService.loadRows, SettingsService.upsertRow
  (existence probe and insert; the update already carried it) and the
  sys_setting_audit writer.
- service-datasource: the sys_metadata helpers behind runtime
  datasources (loadDatasourceRows, loadDatasourceRow,
  persistDatasourceRow, deleteDatasourceRow) and the sys_secret binder
  (bind, unbind, resolve).
- plugin-webhooks: AutoEnqueuer's subscription refresh and the
  redeliver guard's subscription read.
- service-messaging: the dispatcher claim path (SqlNotificationOutbox
  claim / claimDigest / reapExpired, SqlHttpOutbox claim / reapExpired)
  and the emit fan-out (writeEvent, the inbox channel's send with its
  locale read and delivered receipt, PreferenceResolver.loadRows,
  RecipientResolver.resolveEmail).

None of the gates the security middleware runs before its
principal-less hand-off engages on these calls, so what each one reads
and writes today is unchanged; they no longer depend on that hand-off.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…rgument

IDataEngine reads take their execution context in the trailing options
argument, the same position the writes take theirs; the query bag stays
the query. Moves the opt-in there for every IDataEngine read this
change touches (the SettingsEngine and sys_secret binder surfaces keep
it in their single options bag).

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
One pin per package: an engine double behind the package's real call
path records the context every call carries, proves the population ran
(claim UPDATEs and read-backs, both settings write branches, the inbox
template path, the convergence read) and asserts isSystem on each.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…the system opt-in

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…; tenant-audit census re-derived

- The four pins' engine doubles open findOne / update / delete with the
  shared metadata-core dispatch asserts, hold a find's caller bound, and
  implement only the verbs the moved calls use; the engine-double ledger
  records the new pinned coverage.
- The tenant-audit census is regenerated: the write sites that now
  thread the explicit system opt-in moved from carrying no context to
  threading one, and the page's hand-written figures follow the census.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 6, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/plugin-webhooks, @objectstack/service-datasource, @objectstack/service-messaging, @objectstack/service-settings, touching 35 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-settings/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.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f2aa0c9fad592d11f8fac4b293f459bb5ddb792b.

⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-settings/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 37 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 12 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 f2aa0c9fad592d11f8fac4b293f459bb5ddb792b → packageMentionDocs.

Which tree this was computed on

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

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f2aa0c9fad592d11f8fac4b293f459bb5ddb792b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI note from domain:services seat 1: the red Lint & Repo Gates at 62960ffa1a is not this PR's


Generated by Claude Code

…er the system opt-in

The engine skips its referential-integrity check for an isSystem write,
so the fan-out writes (sys_notification and sys_inbox_message actor_id),
the setting-audit write (sys_setting_audit actor_id) and SettingsService's
user-scope insert (sys_setting user_id) now check the reference first:
one sys_user read under the opt-in, refusing an unknown id with the
engine's own answer (VALIDATION_FAILED, one reference_not_found finding,
the same message) and failing open when the read cannot run, as the
engine's probe does. A write that names no user is unchanged.

Differential pins hold each producer's refusal equal to the engine's
answer to the context-less write it made before the opt-in. The changeset
moves to Clause-② yes (widening): SettingsEngine find/insert and
SecretStoreEngineLike delete gained an optional context, so
service-settings and service-datasource take a minor.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl and removed size/l labels Oct 6, 2026
claude added 2 commits October 6, 2026 03:14
…rvices

Brings in the dogfood per-file cwd fix the check:pm-dispatch-gates self-test
reads. No file of this branch is touched by the two incoming commits.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…dationFailure constructor

The producer-side refusal assigned its code in each package, which made both
packages new stamp sites of a registered code with no provenance row. It is
now built by `validationFailure` from `@objectstack/types` (already a runtime
dependency of both), the constructor that already holds that provenance. The
envelope is unchanged: same name, code, message and findings.

The settings pin's find double applies the caller's bound before copying rows,
and the package's vitest config aliases `platform-objects/identity` to source.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 57f738dfb124f9f6a548c41dde81dff6e4d1f1ae
Local-runs: none

① Derived judgments

Judged on the net diff against main and on the main tree read as git objects; classes, positions and functions only.

  1. The route — right. Every engine call in the card's named functions (rows 7, 8, 11, 12, 13, 14) carries the explicit system opt-in. IDataEngine reads put it in the trailing options argument, the position the contract names; the two single-bag surfaces (SettingsEngine, the secret binder's engine slice) put it in the query bag, which the engine also reads (find / findOne merge query.context with options.context, options winning). The production adapter wrapEngineAsSettingsEngine.find forwards the bag whole and .insert forwards the trailing options, so loadRows, the upsertRow probe and insert, and the new user probe all reach the engine flagged.
  2. "No gate before the hand-off fires on these objects" — right, by static read. assertPackageManagedWriteGate keys on sys_permission_set; assertSystemRowWriteGate on sys_position and sys_capability; assertCuratedCapabilityNameGate on sys_capability; assertAudienceAnchorBindingGate on permission-set bindings; assertEngineOwnedWriteAllowed refuses only a write whose context carries a user (isUserContextWrite), which none of these did or do; the delegated-administration gate on the RBAC link tables. None of the objects this slice touches is in any of those sets.
  3. The reads return the same rows — right. The hand-off already applied no RLS, FLS or tenant wall, and the sharing read filter abstains for every object here: effectiveSharingModel resolves an undeclared model on a sys_-named or isSystem object to public, so the deny-all answer the dev flags for resolveOwnerOf (a business object) reaches none of the moved reads.
  4. The writes land the same columns — right. The create-side static-readonly strip (census rows 21 and 22) never ran on these objects for a context-less caller either: staticReadonlyInsertSubject returns null for a sys_-named or platform-managed object, and the only runtime-owned type is autonumber, which no moved insert supplies. The update-side strip (rows 19 and 20) applies everywhere, but no moved UPDATE supplies a readonly key (the datasource row update writes metadata, updated_at, version, state against sys_metadata's readonly id, created_by, created_at; the claim and reap updates write status, claimed_by, claimed_at against the delivery objects' readonly id, created_at). The owner and organization fill-only stamps (rows 2 and 61) had no principal to fill from before or after. Row 24 silences the tenant-audit WARN on the writes that did not already pass bypassTenantAudit: a diagnostic, not an accept-set change, and the census page records the move (item 8).
  5. Row 23, the seat's option B — right and complete. The engine's assertReferencesResolve returns first on isSystem with no option to keep it (so route a does not exist), skips readonly fields, empty values and object values, probes findOne on the target by id projecting id under a bare system context for a context-less write, fails open when the probe cannot run, and throws VALIDATION_FAILED with one reference_not_found finding. assertActorReferenceResolves (messaging) and assertUserReferenceResolves (settings) do each of those in the same order. Coverage: the reference-typed, non-readonly fields these four writes supply are exactly actor_id on sys_notification, sys_inbox_message and sys_setting_audit, and user_id on sys_setting; the inbox and receipt rows' user_id and notification_id are text fields, and updated_by and the injected organization_id are readonly lookups the engine already skipped. The four call sites are therefore the whole set the engine used to check on these writes; the dev's extension to sys_setting.user_id closed the one the seat's verdict did not name. One residual, judged acceptable: the engine iterates an array-valued reference while the producers skip any object-typed value; both inputs are typed string, so no typed caller reaches it.
  6. The refusal's class — right; no accept-set change. validationFailure (@objectstack/types, a runtime dependency of both packages) builds an Error named ValidationError with code VALIDATION_FAILED and fields; validationFailureDetails, which the dispatcher error exits and REST read, matches on code or name, so every door still answers 400 VALIDATION_FAILED. The only instanceof ValidationError consumers on main are inside objectql itself. The inbox SendResult text is unchanged because the check sits inside the existing try. Cost and disclosure: one sys_user existence read projecting id per write that names a user; the refusal names the supplied value, as the engine's did, so nothing new is disclosed.
  7. Public surface — right. SettingsEngine.find and .insert opts gain an optional context, SecretStoreEngineLike.delete options gains an optional context; both types sit on their package entries (widening, see ②). SecretStoreEngineLike.find? is untouched. Nothing from fan-out-system-context, outbox-dispatcher-scope or either actor-reference module is re-exported from service-messaging's or service-settings's entry; plugin-webhooks adds module-local constants only. "No new symbol on any package entry" holds.
  8. Census pages — right. Every hand-written figure changed on tenant-audit-census.mdx is one check-tenant-audit-census enforces against the regenerated block (the historical "published N sites" sentence and its two sub-counts included), and the generated block and docs/audits/…counts.md moved together; content/docs/permissions/ is a hand-written tree, not release-owned. The isSystem census gains no elevation read site: the new code sets the flag and reads it nowhere.
  9. Not re-measured here (read-only): the instrument counts, the zero gate throws and the eleven ablation legs are the dev's claims. What CI runs: one pin per package holding the flag on every call of the moved functions, and two differential pins holding each producer's refusal equal to the engine's over a real engine.

Gate verdicts, from the check-runs on this head (latest run per check name): all 34 check names on this head have concluded, and every check that ran is success — Build Core, Build Docs, Check Changeset, Check Documentation Links, Dogfood Regression Gate (1/3, 2/3, 3/3 and the rollup), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, the two no-other-open-PR claim guards, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (1/6 to 6/6 and the rollup), The card this PR closes must claim this branch, Type Check (consumer gates, debt ledger, source gates, workspace, and the TypeScript rollup), filter. Skipped by design: Console Pin Gate and Packed-tarball smoke (not opted in), and the label-event re-runs of Auto Label and Check PR Size, whose first runs on this head are success. No check on this head concluded failure; the runs on the two earlier heads are superseded.

② Semver level

.changeset/21913-principal-less-producers.md: @objectstack/service-settings minor, @objectstack/service-datasource minor, @objectstack/service-messaging patch, @objectstack/plugin-webhooks patch — right. The two minors are the two packages whose published types widen (item ①.7); an implementation written against the old types still type-checks, and the type's JSDoc states the forwarding rule an adapter now owes. Messaging and webhooks publish no type or symbol change and no accept-set change (a dangling reference is refused before and after; known and absent references are written both ways), so patch is right. The changeset's prose matches the diff: the two named types, the four call sites of the kept refusal, no new entry export, no new elevation API.

Clause-②: PR body line 2 reads Clause-②: yes (widening); the changeset carries the same line; the claim comment on the card was amended to it; Check Changeset on this head is green. The line matches what the diff publishes.

③ Boundary flags

Round 1 (the dev's first report and the seat's verdict on it):

  • open_questions[0], row 23 — the seat ruled B (keep the refusal at the producer); implemented at four producers in patch round 1 (①.5). Answered.
  • Exported interface types changed — the seat corrected the line to yes (widening) and the two levels to minor; PR line 2, changeset and claim moved together (②). Answered.
  • The local instrument edited plugin-security (a stop-list package) and was reverted — no file of plugin-security, packages/spec or packages/qa is in the net diff. Answered.
  • readRecipientLocale moved as part of the inbox send — a module-local helper only send calls, named in the PR table. Answered.
  • The tenant-audit census regenerated with its hand-written figures, and scripts/engine-double-contract.pinned.json gained the new pinned doubles — both gate-required artifacts of the same change (①.8); one changeset file naming four packages stands for the "changeset per package" of the claim. Answered.
  • Measurement on the pre-merge tree; a container restart mid-measurement — not re-measured here; CI on this head is the verdict. Noted.

Round 2 (the dev's patch-round report):

  • Item 1 extended to sys_setting.user_id — the same row-23 difference this PR's own opt-in introduced; the extension is right and is in the changeset and the seat's append (①.5). Answered.
  • The refusal is shape-identical but not an instance of objectql's class — ①.6. Answered.
  • Existing doubles adjusted (one test in messaging-service.test.ts, two in inbox-channel.test.ts, the settings pin) — the doubles now answer a sys_user existence read; no assertion changed. This departs from the card's "the packages' suites are unchanged" and is the necessary consequence of the seat's option B. Accepted, named.
  • service-settings/vitest.config.ts gains an anchored alias for the new pin — test-only, gate-required. Answered.
  • The earlier head was red at check:error-code-provenance — fixed at this head by building the refusal through the shared constructor; no stamp site in the diff. Answered.
  • origin/main moved after the push and was not merged; the dev did not edit the PR body — no overlap claimed; the seat's append is present and current; CI on this head is the verdict. Noted.
  • open_questions: none.

Carried, not this PR's, and recorded where they belong: producers in these packages the card does not name (census rows 24 onward on the closure card; resolveOwnerOf is not neutral); the inbox unread and mark-read request-door producers, joined to the maintainer's open ruling on rows 15 and 16; the fail-open access guards, filed as their own card; one request-door posture candidate held off-thread at class level (an authenticated door's refusal text carrying a configuration row's field) — this PR changes neither what that read returns nor who may reach it, since the flag grants nothing the hand-off did not already pass. Scope held: rows 15 and 16 untouched (listInbox, readReceiptStates, StorageMetadataStore); the serial constraints held (the regions of the two named sibling PRs are untouched); no door's authorization changes; no new elevation API.

Implemented-by: claude/issue-21913-principal-less-producers-services
Reviewed-by: session_011K3zqE8Pv1Evw5hc8tZCnN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 04:58
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/xl tests tooling

Projects

None yet

2 participants