Skip to content

feat(service-mailbox): sys_mail_message, its record associations and the restricted unassociated pool — the inbound-mail capability (manage_mailboxes, requires: mailbox) - #22993

Draft
objectstack-fleet[bot] wants to merge 14 commits into
mainfrom
claude/issue-22870-p2-service-mailbox
Draft

objectstack-fleet[bot] wants to merge 14 commits into
mainfrom
claude/issue-22870-p2-service-mailbox

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22870
Clause-②: yes (widening)

PR 2 of 2 for #22870 (PR 1, FieldSchema.mailAssociation, landed as e5687cb6b). Built from the stage-0 report 6115941082 §3.1, §3.2, §3.4, §3.5 and §4 row 2, under ruling 6116489638 (Q2 → C3, Q4 → A). This PR completes the card's scope: the inbound-mail objects, their registration, both read gates, the boot wiring, manage_mailboxes and the mailbox capability token. The ingest half (connector-imap, ingest, dedupe, auto-reply suppression, re-match, MIME parsing, the source marker, sys_mailbox's connector columns) stays with #22871.

What changes

  • New package @objectstack/service-mailbox (ADR-0029 D1/D2: a capability owns its objects, registration and gates). MailboxServicePlugin registers three engine-owned objects, data API get and list only:
    • sys_mail_message: the RFC 5322 mirror (stage-0 §3.1, 18 columns). body_html is textarea, never html. enable.files: true, so attachments are sys_attachment rows whose gate inherits this object's gate. The unique key is (mailbox_id, dedupe_key) per organization.
    • sys_mail_association: one row per message-to-record link (§3.2), with the ActivityPointer pair object_name and record_id (referenceVia: 'object_name'). The unique key (mail_message_id, object_name, record_id) makes re-match idempotent.
    • sys_mailbox: the identity half only, name, address and owner_user_id, with requiredPermissions: ['manage_mailboxes'].
  • Two read gates (mail-read-visibility.ts, §3.4), registered as data middleware on find, findOne, count and aggregate, so a list's total agrees with its rows:
    • Message gate. A message is served to a caller who can read a record that one of its associations names, to the owner of its mailbox, or to a holder of manage_mailboxes. A pool message has no association, so an ordinary member reads it as 404 RECORD_NOT_FOUND.
    • Association gate. A row is served to a caller who can read the record it names, or to a holder of manage_mailboxes.
    • Both use a bounded system pre-scan (2,000 rows) and fail closed. System reads are not narrowed. If the engine has no middleware seam, the plugin logs at error and names the consequence.
  • manage_mailboxes (Q4 → A) is declared in PLATFORM_CAPABILITIES with scope: 'org'. It is held by default through ADMIN_FULL_ACCESS_CAPABILITIES and by organization_admin, and so by its derived no-bypass variant. Its readers are both gates and sys_mailbox.requiredPermissions.
  • mailbox capability token: it is in PLATFORM_CAPABILITY_TOKENS and PLATFORM_CAPABILITY_PROVIDERS (open edition), and it has a CAPABILITY_PROVIDERS row in core. @objectstack/cli and @objectstack/verify depend on the package, so serve and bootStack mount it. The showcase now declares requires: [..., 'mailbox']. The token is not on the always-on slate.
  • Hoisted resolver. resolveReadableParentIds moves to @objectstack/core (packages/core/src/security/readable-parent-ids.ts). The plugin-audit export and the service-storage private copy are deleted, and both packages import it from core. The two bodies were byte-identical (compared before the move), so no existing gate changes its answer.
  • Pins and generated files:
    • the service-mailbox group in platform-object-names.ts;
    • three ADR-0131 inventory rows (fate attribution; the message is anchored to its mailbox, the association to its message);
    • platform-object-tenancy-census.json, regenerated: 87 registered, 52 in reach;
    • i18n bundles for en, zh-CN, ja-JP and es-ES, translated by hand. The coverage baseline is 0 untranslated;
    • the plugin-security grant goldens (the platform and organization admins now carry manage_mailboxes);
    • the lockstep package count (71) and the llms.txt package count (70);
    • a new row 47 on content/docs/permissions/system-context.mdx, for the gates' isSystem bypass. Later rows are renumbered, the rows 1–62 and row 60 references follow, and the census gate's ledger and self-test track the shift.

Verification record (head 204aa2e8d)

  • Showcase boot through the data API: packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts, 9/9. This is the §3.4 probe table, and every refusal asserts both status and code:

    Probe Measured
    reader reads a message associated with its own contact (positive control) 200
    reader reads the unassociated pool message (the pin) 404, RECORD_NOT_FOUND
    reader reads a message associated only with a contact it cannot read 404, RECORD_NOT_FOUND
    reader lists the mailbox only the associated message; total is 1
    reader lists associations only the one naming its own record
    mailbox owner reads and lists pool served; total is 3
    manage_mailboxes holder, and the seeded platform admin, read the pool (exemption control) 200
    member with no object grant reads the pool (the 403 control) 403, PERMISSION_DENIED
    the mailbox owner reads sys_mailbox (object grant held, capability not) 403, PERMISSION_DENIED; the holder gets 200
  • Ablation (scripts/ablation-replace.mjs, then a rebuild, then scripts/ablation-dist-preflight.mjs):

    • Mutation. installMailReadVisibility returned before registering either middleware. The marker reached dist/ in 2 built files. The mutated tree's dts step failed on a narrowing the mutation itself caused, but the JS was emitted, and the preflight proves the marker is in it.
    • Result. The pin went red with 4 failed and 5 passed: the pool read expected 200 to be 404, the foreign read likewise, the list total was 2 instead of 1, and the association list likewise.
    • Restore. The blob equals HEAD, git diff HEAD is empty, git status --porcelain is empty, the marker is absent from dist/, and the suite is 9/9 again.
    • A void first attempt. It carried its marker only in a comment, which the build strips, so the mutated build left no marker in dist/. Its reading was discarded and the ablation was redone with the marker in a string literal.
  • Package suites:

    • @objectstack/service-mailbox 14/14: plugin registration, the provider identity, the capability pins, object shape, and the write-door check with its control;
    • @objectstack/core 2381;
    • @objectstack/plugin-audit 743;
    • @objectstack/service-storage 846;
    • @objectstack/plugin-security 4057 passed, 45 skipped;
    • @objectstack/verify 250;
    • @objectstack/platform-objects 1083;
    • @objectstack/example-showcase 409;
    • @objectstack/cli unit 4265, with the 2 identity-pin failures fixed and re-run;
    • spec src/kernel, src/security, src/identity and src/system/constants, 1738;
    • dogfood: authz-conformance, authz-probe-blind-spot, platform-app-object-entry-views, the attachment, comment, activity and ledger gate suites, the audit-capability suite, security-catalog-showcase and the new suite, 244 passed, 1 skipped. The attachment, comment, activity and ledger gate suites are the behavioural proof that the resolver hoist changes nothing.
  • typecheck: green for core, plugin-audit, service-storage, plugin-security, verify, service-mailbox, dogfood, example-showcase, spec and cli.

  • eslint (--no-inline-config) on the 42 changed .ts/.mjs files: 0 errors, 0 warnings. This is a targeted run, not a proof of narrowing; the repo-wide pnpm lint is CI's.

  • Gates: node scripts/pm/dispatch-gates.mjs --commands derived 155 commands on 204aa2e8d, and all exit 0. The --ran reconciliation reports 155 derived, 155 run and 0 NOT-MEASURED, a derived zero. check:generated: all 14 artifacts up to date.

  • check:platform-checklist (outside per-PR CI) exits 1 with 8 problems on the base e23eccb16 and the same 8 on the head; the problem sets are identical line for line. The hoist removed no symbol that the checklist anchors.

  • Size: 2891 changed lines (+2739 / -152, 58 files, generated files included), under 3000.

Acceptance notes

  • The resolver's shared home is @objectstack/core. All three consumers already depend on it, and it already hosts withoutOperationPrivateKeys. A ReadableParentIdsEngine structural type keeps core free of an engine dependency. ADR-0139 symbol-anchored the old location, so its one pointer is re-anchored to packages/core/src/security/readable-parent-ids.ts#resolveReadableParentIds, the pointer alone with no prose change. This puts docs/adr/**, a Tier H path, in the diff, so the PR lands only on a maintainer approval.
  • Files outside the claim's listed surface, each needed mechanically by the listed work:
    • packages/cli/package.json and packages/verify/package.json: the provider table's readers must declare the provider package;
    • .changeset/config.json: the new package joins the fixed group;
    • packages/qa/dogfood/test/…: the showcase boot test lives with the other showcase boots, because a test inside the package would need @objectstack/verify and the example as dev dependencies, a cycle;
    • packages/cli/test/serve-capability-identity.test.ts: the identity pin;
    • content/docs/permissions/system-context.mdx and scripts/check-system-context-census.mjs: required by the census gate;
    • packages/spec/llms.txt, content/docs/protocol/backward-compatibility.mdx and scripts/publish-smoke-pack.mjs: package counts;
    • docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md: the anchor the hoist moved;
    • packages/services/service-mailbox/README.md: the README its files[] lists;
    • scripts/i18n-coverage-baseline.json: the new package's coverage entry.
  • Liveness. Per stage-0 §3.5, object columns carry no ledger rows. manage_mailboxes and mailbox land together with their readers in this PR. The packages/spec/liveness/README.md field-cell sentence was not ridden.
  • Writer window. Nothing writes these rows until connector-imap + minimal service-mailbox: shared-mailbox (forward/BCC) ingest into sys_mail_message, associated at ingest through the seam — phase 1 of #8998 inbound mail #22871 lands. Every object is engine-owned with get and list only, so no author or API caller can create rows that nothing reads.
  • Organization stamp. The dogfood fixture inserts as the system with the reader's organization, the way ADR-0131 D9 requires the ingest service to stamp. The cross-organization negative test that §3.2 lists belongs to connector-imap + minimal service-mailbox: shared-mailbox (forward/BCC) ingest into sys_mail_message, associated at ingest through the seam — phase 1 of #8998 inbound mail #22871's matcher.
  • Labels. No labels are written: this dispatch's write budget names none.

维护者速读(草稿)

  • 改了什么:新增 @objectstack/service-mailbox 能力包。应用在 requires 里写上 'mailbox' 后,平台会注册三张入站邮件系统表:邮箱(sys_mailbox)、邮件镜像(sys_mail_message)、邮件与业务记录的关联(sys_mail_association)。同时装上两道读取门,并新增能力名 manage_mailboxes,平台管理员和组织管理员默认持有。另把一个“判断调用者能否读某条父记录”的公共函数从两个包挪进 @objectstack/core,ADR-0139 里指向它的那一个链接随之更新,ADR 正文不变。
  • 为什么改:入站邮件第一阶段(spec + service-mailbox: sys_mail_message (the inbound mirror object) and the association seam — phase 1 of #8998 inbound mail, spec seat #22870)要求“未匹配到任何记录的邮件池默认受限,只有邮箱管理员和邮箱所有者可见,不对全组织开放”。现在普通成员只能读到与自己能看的记录关联的邮件;读池中邮件得到 404,按“不存在”处理,不泄露存在性。真正收信的 IMAP 接入属于 connector-imap + minimal service-mailbox: shared-mailbox (forward/BCC) ingest into sys_mail_message, associated at ingest through the seam — phase 1 of #8998 inbound mail #22871,本 PR 不包含。
  • 风险与代价(含回滚):三张表只在应用声明 mailbox 能力时才注册,不声明的部署零影响。公共函数的搬家经逐字节比对,原附件、评论、动态、审计日志的读取门行为不变,对应 dogfood 套件全绿。代价是组织管理员默认多了一个能力。回滚:revert 本 PR 即可;由于还没有写入方,不会留下数据。
  • 席位意见:
  • 你要做的(一个动作):因为本 PR 改动了 docs/adr/**(只是 ADR-0139 的一个链接),需要你在 PR 上点一次 Approve,之后由席位走合并队列落地。

Generated by Claude Code

claude added 10 commits October 12, 2026 06:20
…, one probe for every parent-derived read gate

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
…nd both read gates (WIP)

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
…wcase requires mailbox

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
…e data API on a showcase boot

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
…t shape; changeset

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
…xes for platform and organization administrators

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
…ass is a row on the authority page; llms.txt counts the new package

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
…ed) and the lockstep package count

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
…tests; cli pins the mailbox provider identity

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
system-context.mdx: main's page taken, then the inbound-mail gates' row re-inserted as row 47 and the later rows, the prose references and the census ledger re-derived by the census gate (gen:system-context-census).
backward-compatibility.mdx: main's prose taken, the lockstep package count re-derived by check-lockstep-package-count --fix (71).

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system tests tooling labels Oct 12, 2026
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 8 package(s): @objectstack/cli, @objectstack/core, @objectstack/plugin-audit, @objectstack/plugin-security, @objectstack/service-mailbox, @objectstack/service-storage, @objectstack/spec, @objectstack/verify, touching 85 documentable anchor(s). ⚠️ 11 changed file(s) yielded no anchor (packages/cli/package.json, packages/plugins/plugin-audit/src/parent-record-read-gate.ts, packages/services/service-mailbox/LICENSE, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

74 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 df7a137923be503bf6fea15a6fc944d3ba06df6f.

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

What this run could not see
  • 11 changed file(s) yielded no anchor (packages/cli/package.json, packages/plugins/plugin-audit/src/parent-record-read-gate.ts, packages/services/service-mailbox/LICENSE, …) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: isSystem (5 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 40 pages)
  • 14 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 — 156 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 df7a137923be503bf6fea15a6fc944d3ba06df6f → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 df7a137923be503bf6fea15a6fc944d3ba06df6f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…s (packages/core)

Claude-Session: https://claude.ai/code/session_013tMiyVNzCwaUhgfUe6gg8y
Co-authored-by: Claude <noreply@anthropic.com>
system-context.mdx: main's page taken, then the inbound-mail gates' row re-inserted as row 47 and the later rows, the prose references and the census ledger re-derived by the census gate (gen:system-context-census).

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 204aa2e8d4ced58a1f6bbf7df3a2381b33ac5780
Local-runs: none

Isolated at-tier subagent of domain:spec seat 1 (#6017), reading at 2026-10-12T15:55Z. Inputs: card #22870 (its body and all 16 comments: the stage-0 report 6115941082, the ruling 6116489638, the claim 6118835291, the dev reports 6120288712 and 6126279669, the seat's review 6124120437 with its in-place correction), PR #22993 (its body, its 58-file list, the diff against the merge base df7a137923), and the 50 check-runs on the head. Nothing was built, run or re-run; file contents beyond the diff were read with git show at the head and at the merge base.

Head and checks. The REST head.sha equals the brief's sha. Check-runs on it: 50 — 43 success, 7 skipped (Console Pin Gate, Dogfood Verify CLI, the dogfood matrix placeholder, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL), and the re-triggered Auto Label and Check PR Size), 0 failure, 0 in progress. Of the seven required contexts six are success and Temporal Conformance is skipped. mergeable: true, mergeable_state: unstable, draft: true, auto_merge: null; 2,739 additions and 152 deletions, 2,891 changed lines, under 3,000.

① Derived judgments

Published schema (packages/spec/src/**), four accept-set widenings, each RIGHT:

  1. security/capabilities.ts: PLATFORM_CAPABILITIES gains manage_mailboxes, scope: 'org', with label and description. Ruling 6116489638 Q4 → A as written, in the view_all_audit_log precedent's shape. Its readers land in the same diff — both gates (MAILBOX_ADMIN_CAPABILITY) and sys_mailbox.requiredPermissions — so declared = enforced holds for the name.
  2. identity/eval-user.zod.ts: ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions gains manage_mailboxes; platform administrators hold it by default. RIGHT (Q4 → A).
  3. kernel/platform-capabilities.ts: mailbox joins PLATFORM_CAPABILITY_TOKENS and PLATFORM_CAPABILITY_PROVIDERS (@objectstack/service-mailbox, open), and not the always-on slate. RIGHT (ADR-0029 D2; stage-0 §4 PR 2).
  4. system/constants/platform-object-names.ts: the service-mailbox group names exactly the three objects the plugin registers. RIGHT.
    No spec export is removed or narrowed; the dev reports no spec shard moved, and TypeScript Type Check (the job that runs check:generated) is success.

Other public surfaces:
5. @objectstack/core gains resolveReadableParentIds and the ReadableParentIdsEngine type at its root entry (src/index.ts re-exports ./security/index.js), plus the CAPABILITY_PROVIDERS.mailbox row. RIGHT: stage-0 §3.4 said a third consumer hoists the probe rather than copying it; all three consumers already depend on core; the structural engine type keeps core free of an engine dependency.
6. @objectstack/plugin-audit: the exported resolveReadableParentIds in comment-access-hooks.ts is deleted. Verified at the merge base: the package index re-exports only installCommentAccessHooks, installCommentReadVisibility, parseCommentThreadId and types from that module, and the exports map has . alone, so the symbol was never in the published accept set and this is not a narrowing. parent-record-read-gate.ts and comment-access-hooks.ts now import it from core. RIGHT.
7. @objectstack/service-storage: the private copy in attachment-access-hooks.ts is deleted and the import points at core. RIGHT (the two bodies were measured byte-identical; the core body in the diff is that body under a structural engine type).
8. @objectstack/plugin-security: organization_admin.systemPermissions gains manage_mailboxes, and the derived organization_admin_no_bypass carries it; the six grant goldens move with it in both postures, single and walled. RIGHT (Q4 → A).
9. @objectstack/service-mailbox (new, 17.7.0, in the fixed group): MailboxServicePlugin (manifest com.objectstack.service.mailbox, dependencies: ['com.objectstack.engine.objectql']) registers sys_mailbox, sys_mail_message and sys_mail_association; the root entry exports the plugin, the objects, the gate functions, constants and types. RIGHT per ruling Q2 → C3 and ADR-0029 D1/D2.
10. @objectstack/cli and @objectstack/verify depend on the new package so serve and bootStack can mount the provider; the cli inventory gains three ADR-0131 rows (fate attribution; the message anchored to its mailbox, the association to its message) and the identity pin a mailbox row. RIGHT.
11. The showcase declares requires: [..., 'mailbox'] (a private example). RIGHT (stage-0 §4).

The objects against the build sheet (stage-0 §3.1 and §3.2, the claim's identity half), each RIGHT:

  • sys_mail_message: isSystem, managedBy: 'engine-owned', enable: { apiMethods: ['get', 'list'], files: true }, nameField and titleFormat on subject, the four highlight fields, and the 18 §3.1 columns at the §3.1 types and bounds (message_id 998; addresses 320 and 4,000; dedupe_key 128, required; received_at required; association_state a select defaulting to unassociated; from_address and subject optional; no Bcc). body_html is textarea, never html. Indexes: (mailbox_id, dedupe_key) unique per organization, (association_state, received_at), (mailbox_id, received_at).
  • sys_mail_association: mail_message_id lookup; the ActivityPointer pair object_name / record_id (referenceVia: 'object_name', 255 so the index is expressible); matched_address 320, matched_field 255, matched_by select ingest / rematch. Unique (mail_message_id, object_name, record_id) per organization, plus (object_name, record_id). engine-owned, get and list.
  • sys_mailbox: name, address (email, 320), owner_user_id lookup sys_user; requiredPermissions: ['manage_mailboxes']; engine-owned, get and list. The connector half stays with connector-imap + minimal service-mailbox: shared-mailbox (forward/BCC) ingest into sys_mail_message, associated at ingest through the seam — phase 1 of #8998 inbound mail #22871.
  • No member_default grant is added (stage-0 §2.4: explicit-allow).

The two read gates (mail-read-visibility.ts), on the security posture the ruling and the card's acceptance require:

  • Restricted pool default — RIGHT. The message filter serves a candidate when (a) the caller holds manage_mailboxes in its resolved systemPermissions (then no filter at all), (b) the caller's userId owns the row's mailbox (sys_mailbox.owner_user_id, read as the system), or (c) one of the row's sys_mail_association rows names a record resolveReadableParentIds answers readable under the caller's own context. A pool row has no association, so only (a) and (b) serve it. The association filter serves a row under (a) or when its own pair is readable. This is stage-0 §3.4 and ruling Q4 → A as written.
  • Fail-closed — RIGHT. The pre-scan runs as the system at MAIL_GATE_SCAN_LIMIT (2,000, the PARENT_GATE_SCAN_LIMIT value) in the caller's order; rows beyond the cap never enter the emitted $in, and a warn says so. A malformed pair, a mail object named as the parent (MAIL_OBJECTS), a non-machine name or an unreadable or unknown parent contributes nothing; zero visible rows emits the deny-all sentinel; a thrown filter computation ANDs the deny-all sentinel and logs. The filter is ANDed into the caller's WHERE, never replacing it, so the caller's own read still passes the object grant and the organization wall.
  • Coverage — RIGHT. Registered as object-scoped middleware on find, findOne, count and aggregate, so total agrees with the rows (pinned: the reader's list total 1, the owner's total 3).
  • System and context-less reads un-narrowed — RIGHT by precedent. The entry test (not a read op, no ast, no context, or context.isSystem → pass through) is the shape of the activity, ledger, comment and attachment gates at this head; the isSystem bypass is row 47 on content/docs/permissions/system-context.mdx, and the census gate inside Lint · docs & code guards is success.
  • Exemption and holders — RIGHT. manage_mailboxes is read from context.systemPermissions, the channel the authorization resolver stamps and the ledger gate's view_all_audit_log exemption reads. Holders by default: platform administrators (ADMIN_FULL_ACCESS_CAPABILITIES) and organization administrators (organization_admin and its no-bypass derivative); every other position by explicit grant only. The dogfood fixture grants it explicitly to holder, and the seeded platform admin reads the pool as a default holder.
  • Owner limb — RIGHT. sys_mailbox.owner_user_id equals the caller's userId; the owner is served every message of its mailbox (pinned: the pool 200, total 3) but not the directory itself (pinned: 403 PERMISSION_DENIED on sys_mailbox; the holder 200).
  • The 404 and 403 answers — RIGHT. An ordinary member holding object read on the message reads the pool row as 404 RECORD_NOT_FOUND (the card's acceptance pin, status and code both asserted; existence non-disclosure per stage-0 §2.4), and a message associated only with a record it cannot read likewise; a member with no object grant is refused 403 PERMISSION_DENIED at the object, which proves the pin's refusal is the gate's. The dev's ablation (gates left unregistered → 4 of 9 red, expected 200 to be 404, total 2 not 1) shows the pin is load-bearing; the suite runs under Dogfood Regression Gate, success on this head.
  • Boot wiring — RIGHT. The gates install on kernel:ready; an engine without the middleware seam logs at error with the consequence (the pool unrestricted) and the remedy, per the degradation rule, and the unit test pins the message.

Generated and derived files, each RIGHT: the tenancy census (87 registered, 52 in reach; three new in rows on organization_id); the four locale bundles with their source-hash companions wired through withSourceFallback; the i18n coverage baseline entry at 0; the lockstep package count 70 → 71 and llms.txt 69 → 70; the .changeset/config.json fixed group; system-context row 47 with the later rows renumbered and the census script's row 60 and rows 1–62 references and self-test expectations moved with them; the inventory rows; the grant goldens.

The governed edit, docs/adr/0139-… line 63: one line, in which only the anchor path inside the backticks moves from packages/plugins/plugin-audit/src/comment-access-hooks.ts#resolveReadableParentIds to packages/core/src/security/readable-parent-ids.ts#resolveReadableParentIds. No prose changes; the sentence is location-neutral; no decision in ADR-0139 (D1–D6 declare the engine leaf and implement nothing) is moved. At the head no other file in the tree names the old anchor spelling and no scripts/adr-anchors/ entry names the symbol; Lint · docs & code guards, the job the seat measured red on 5866e6e1f for this anchor, is success. RIGHT — and it puts docs/adr/**, a GOVERNED_TIER_H register row, in the diff.

② Semver level

.changeset/22870-service-mailbox.md: @objectstack/spec minor, @objectstack/service-mailbox minor, @objectstack/core minor, @objectstack/plugin-security minor, @objectstack/plugin-audit patch, @objectstack/service-storage patch, @objectstack/cli patch, @objectstack/verify patch. Checked against the diff: every published package whose shipped source moves is named (the two other packages touched, @objectstack/dogfood and @objectstack/example-showcase, are private: true); the four minors are the four surfaces that widen (①.1–5, 8 and 9); the four patches change no published export (①.6, 7 and 10). Nothing narrows: the one deleted export was never addressable through plugin-audit's exports map, so no ADR-0087 disposition is owed and check-changeset-no-major has nothing to refuse (Check Changeset is success). The changeset's one bullet per package matches the diff.

Clause-②: yes (widening), in the PR body and in the changeset body, matching the claim and the ruling; yes takes at least minor on spec, which it has. Level RIGHT.

③ Boundary flags

Dev report 6120288712 (head 76b1da0da):

  • deviations: the files outside the claim's surface — each judged above with the reader that needs it (①.10, the dogfood suite's home beside the other showcase boots, the census row, the package counts, the baseline, the goldens): answered. The worktree re-creation — process only, no diff effect. The missing README — closed by the second round (packages/services/service-mailbox/README.md is in the diff and states only what ships, with its "nothing ingests mail yet" warning). Zero label writes — consistent with the dispatch. The commit-trailer form — AGENTS.md's; not a contract matter.
  • open_questions: none.
  • out of scope: the ADR-0139 prose pointer — superseded by the seat's in-place correction in 6124120437 (the symbol-anchor gate does read it) and closed by the pointer edit judged above. The packages/spec/liveness/README.md field-cell sentence — not carried; prose no gate reads; not owed by this PR, and it stays the note PR 1's acceptance already carries.

Dev report 6126279669 (this head):

  • deviations: the PR body replacements (a) to (e) and the 维护者速读(草稿) section — all applied in the body as read (the verification record at 204aa2e8d, 155 gates, 2,891 lines and 58 files, the resolver bullet naming the re-anchor and Tier H, the two added list items, the section verbatim). The worktree — no bearing. The lint job's 57 steps run through a runner after one refused batch command — local evidence only; the gate verdict is Lint · docs & code guards success on this head. The seat's orders taken as data, and the merge — right: the head is a merge of origin/main with the conflicts re-derived, and CI ran on it.
  • open_questions: none.
  • out of scope, class a: scripts/pm/dispatch-gates.mjs --commands derives gate families from touched paths, so a diff that deletes an ADR-anchored symbol without touching docs/adr/** never derives the symbol-anchor family; CI caught it on 5866e6e1f. Escalated to the dispatching seat as a tooling finding to file (a named producer; reach: every PR that moves or removes an anchored symbol). Nothing in this diff is owed for it. The corrected "no anchor gate reads it" line — closed by the body replacement.

The seat's review 6124120437 owed the merge and the README before this review: both are on the head. Its correction asked for the pointer alone, with no prose change, and that is what the diff carries.

One note from the ADR this PR touches, escalated, not a finding against the diff: ADR-0139 D4 and its E5/E7 cards enumerate six probe consumers as read on origin/main d8830c28; the two mail gates are a seventh and an eighth consumer of today's bounded system pre-scan, the ruled §3.4 shape chosen with the ledger gate as precedent. The record implements nothing and forbids no new consumer (D6 leaves even a seventh open), so the PR is consistent with it, but the E7 switch card should name the mail gates, or they stay on the probe after the others move. For the seat to route to the domain:engine lane.

Landing: this record satisfies the contract-review requirement a Clause-②: yes diff owes, and a PASS record is a Tier S carrier only. The diff touches docs/adr/**, a Tier H row, so the PR lands only on an authorized APPROVED review by a GOVERNED_APPROVERS account, after which the owning seat lands it through the queue; until then it stays draft and unarmed (Prime Directive #14). The pre-queue condition that every check on the head is green holds.

Implemented-by: claude/issue-22870-p2-service-mailbox
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

domain:spec seat 1 (#6017) · os-warren · session session_019G5ZnRrTMiwcHG6iZM19zf · 2026-10-12T15:57Z. This is the final version, checked by the seat against the diff it read. The draft is in the PR body.

  • 改了什么:新增 @objectstack/service-mailbox 能力包。应用在 requires 里写上 'mailbox' 后,平台会注册三张入站邮件系统表:

    • 邮箱目录(sys_mailbox);
    • 邮件镜像(sys_mail_message);
    • 邮件与业务记录的关联(sys_mail_association)。

    同时装上两道读取门,并新增能力名 manage_mailboxes,平台管理员和组织管理员默认持有。另外,把「判断调用者能否读某条父记录」的公共函数从两个包挪进 @objectstack/core,只留一份。ADR-0139 里指向它的那一个链接随之更新,ADR 正文一个字都没改。

  • 为什么改:入站邮件第一阶段(spec + service-mailbox: sys_mail_message (the inbound mirror object) and the association seam — phase 1 of #8998 inbound mail, spec seat #22870)要求未匹配到任何记录的「邮件池」默认受限,只有邮箱管理员和邮箱所有者可见,不对全组织开放(裁决 Q4 → A)。现在的规则:

  • 风险与代价(含回滚):

    • 三张表只在应用声明 mailbox 能力时才注册,不声明的部署零影响。
    • 公共函数挪家前后逐字节一致,附件、评论、动态、审计日志四道原有读取门的行为不变,对应 dogfood 套件全绿。
    • 代价:组织管理员默认多了一个能力。
    • 回滚:revert 本 PR 即可。目前没有任何写入方,不会留下数据。
  • 席位意见:建议批准。

    • 实现与裁决 Q4 → A 逐条一致:默认受限、失败即拒绝、计数和列表结果一致、豁免与所有者两条放行规则。
    • at-tier 契约复审 PASS(6127813917),CI 全绿(43 成功、7 跳过、0 失败),改动 2891 行,低于人工合并门槛。
    • 本 PR 之所以要你批,只因为它碰了 docs/adr/**,而那一处只是一个链接。
  • 你要做的(一个动作):在本 PR 上点一次 Approve。之后由席位跑入队前检查,并走合并队列落地。

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Batch #331 governed row G1 · maintainer 「同意」 · director seat, summon #39 · 2026-10-12T22:45Z

Recorded by the director seat (session_01TJKP6W6UDYEBHtccVdpVoz, GitHub os-steve; written as objectstack-fleet[bot] through the relay). Batch #331 carried this PR as its one governed-draft row (what changed, why, the seat's opinion, the one action: approve on PR #22993). The maintainer's word on the batch was 「同意」, and the authorized approving review by os-zhuang is on this PR, read back after that word.

Landing is the owning seat's: domain:spec seat 1 (#6017, session_019G5ZnRrTMiwcHG6iZM19zf) runs the pre-queue checks (check-governed-merges.mjs --pr 22993; every check green on the head; the PASS record 6127813917 on 204aa2e8d), clears the PR's needs-user-decision label, readies the PR and arms the queue, then posts the landing record on #22870 with the carry to #22590 the ACCEPT names. ⛔ This seat does not land it. If the owning seat is not seated at the next patrol, the orphan-PR check in check-half-states.mjs surfaces it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation needs-user-decision protocol:system size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec + service-mailbox: sys_mail_message (the inbound mirror object) and the association seam — phase 1 of #8998 inbound mail, spec seat

3 participants