Skip to content

fix(plugin-approvals, plugin-audit)!: a lookup title is served only for a target whose declared exposure serves get - #22766

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22738-lookup-title-exposure
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22738-lookup-title-exposure

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22738
Clause-②: no (narrowing)

Census rows 5 and 6 of #22661. Classes, positions and functions only.

What changed

Two reads followed a lookup to the TARGET object's title under a system context and never asked the target's declared exposure. Each now asks the spec's one decision, canServeApiOperation (@objectstack/spec/data, ADR-0049), for get before it reads: the operation the data door's $expand and the dataset door's labels already ask of the same target (#22735). No second rule, no new error code, and no new package dependency (both packages already depend on @objectstack/spec).

  • Row 5, the approvals inbox (plugin-approvals, approval-service.ts). ApprovalService.servesPayloadDisplayTarget is asked in ApprovalService.enrichRows before the referenced-title read. A refused target is not read. Its key gets no payload_display entry, and the snapshot's stored id stands, which is what a deleted target already answers.
  • Row 6, the activity summary (plugin-audit, audit-writers.ts). servesSummaryTitleTarget is asked in resolveLookupTitles, where both the tracked-change branch and the milestone branch end. A refused target is not read, and the summary names the record by its stored id, which is what an unresolvable reference already answers. Only rows written from now on change. No stored row is rewritten, and no other activity column changes. The audit lane only tightens what it serves here and widens nothing.
  • Where the declaration comes from (measured). Both read the target's enable block through engine.getSchema(target), which is ObjectQL.getSchema, the schema registry's getObject. That is the registry the data door's exposure gate and the analytics door's declaration provider read. A throwing read withholds the title and logs one warn line (fail-closed, as servesLabelTarget does). An engine without getSchema resolves no lookup field in either package (resolveLookupFields and the summary's read plan come back empty), so nothing is served on that branch either.
  • The enumeration pin (packages/core/src/security/second-object-read-exposure.pin.test.ts). Rows 5 and 6 move from open to decided. Each row is held to its decision function, its call site and its behaviour pin.

Cross-domain path

The fix lands in two domain:services packages, plugin-approvals and plugin-audit. The path was declared in the claim and posted to the services seat (#6021) before any edit. Nothing in either package outside the title resolution is touched.

Deviation from the suggested route

For a refused key, the inbox sends NO payload_display entry rather than writing the stored id into payload_display. payload_display maps a key to a resolved display value, and the console's inbox card drops an unresolved reference rather than render its id. Writing the id there would make the card render an opaque id as if it were a resolved title. The stored id still stands in payload, which is the answer a deleted target gets.

Measurement (real stack, fixture objects, administrator and member)

  • Before (bf515e724d): both positions served the title of every unexposed target shape (the off switch, a whitelist without get, and the deny-all whitelist) to both personas. The exposed control served its title.
  • After: the unexposed targets answer the stored id at both positions for both personas, and the control is unchanged.
  • Armed before anything is believed: the data door answers each target's get as the decision does (404, 405, 405, 200), and the request and the activity row exist at rest.
  • Producers (measured on bf515e724d). Ten in-repo objects refuse get by declaration. Exactly one lookup points into any of them, from an object that is itself apiEnabled: false (control: 88 lookups into sys_user). No shipped object's served title changes.

Pins and ablation

  • plugin-approvals/src/payload-display-target-exposure.test.ts: 2 cases. A refused target is not read, and the served ones keep their titles. An unreadable declaration withholds, with a warn line.
  • plugin-audit/src/audit-lookup-summary.test.ts: 2 new cases, the same two properties, through a real ObjectQL (19/19 in the file).
  • packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts: 4 cases (inbox list and item, activity summary; administrator and member). 4/4 pass at fb7da543d0 after a post-merge rebuild of the closure.
  • Ablation, via scripts/ablation-replace.mjs (anchor hit 1 to 0, blob changed; every restore proved blob equal to HEAD with an empty git diff HEAD):
    • A. Approvals decision bypassed: approvals pin 2 red of 2.
    • A2. Approvals fail-closed branch opened: 1 red, 1 green (both re-run on 4437a3219f).
    • B. Audit decision bypassed: audit file 1 red, 18 green.
    • B2. Audit fail-closed branch opened: 1 red, 18 green.
    • C. Both bypassed with a marker that survives the build, both packages rebuilt, and ablation-dist-preflight found the audit marker in 2 built files: dogfood 4 red of 4.
    • C-appr. Approvals alone, with the marker in 2 built files: dogfood inbox 2 red, activity 2 green.
    • Restore legs: rebuilt, both markers --absent, tree clean, dogfood 4/4 green.
    • D. Each new decision function replaced by a hand-spelled rule: the enumeration pin goes red, naming the function.
    • Note on the builds. The first leg-C approvals build failed at the DTS step (TS6133, the import left unused by the mutation), so the approvals preflight never ran in that leg. C-appr re-ran it: the JS was emitted before the DTS step, and the preflight found the marker in dist. @objectstack/plugin-approvals also resolves to source in the dogfood project (alias).

Verification (final head 4437a3219f unless noted)

  • Gates. dispatch-gates --commands --repo objectstack-ai/objectstack derived 73 families (they include all 53 named in the dispatch). All 73 exit 0, each exit captured before any pipe. --ran reports 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero. check:dual-build-cjs-loads answered PREREQUISITE NOT MET until a full build (72 tasks). On the first run, check:objectql-double-limit flagged the approvals pin's engine double as unjudged. The double now answers a table whole, because what the pin holds is which objects are read.
  • Package tests. plugin-approvals 72 files / 1025 tests and plugin-audit 45 / 716 at c4f5cfa94a, before merging origin/main, which touches neither package. core repo project 5 files / 55 tests.
  • Typecheck. Exit 0 for plugin-approvals, plugin-audit, core and dogfood, each new test present in its program (--listFiles).
  • Lint (narrowed). eslint --no-inline-config --format json over the 6 changed .ts files: 6 files, 0 errors, 0 warnings (5 to 6 active rules each, read from --print-config). eslint.config.mjs enables no type-aware linting, so no untouched file's verdict can move. The repo-wide pnpm lint is CI's.
  • Line budget. 7 files, +509 / -15. Source is +68 / -6. The real-stack pin is 220 of the lines: it measures the served doors for both personas, which a unit double cannot reach because each read runs under a system context.

Acceptance notes

  • This narrowing owes one contract-review-tier review before the queue. The seat arranges it.
  • The sys_user display names (submitter, approvers, the activity actor) are fixed-target reads. The enumeration pin names them and does not hold them. sys_user serves get, so a user reference field is unchanged.

Generated by Claude Code

…approvals inbox and the activity summary

Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com>
…target's declared exposure serves get

ApprovalService.enrichRows (payload_display) and resolveLookupTitles (the
activity summary) ask the spec's one exposure decision of the referenced
object before reading its title; a refused or unreadable declaration withholds
the title and the stored id stands.

Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com>
…us rows 5 and 6 decided

Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com>
…arrowing of two served lookup titles

Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com>
…le (check:objectql-double-limit)

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-approvals, @objectstack/plugin-audit, touching 8 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/api/wire-format.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class))
  • content/docs/concepts/metadata-driven.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/data-modeling/import-mappings.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/index.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/protocol/objectql/security.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/ui/create-vs-edit-form.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/ui/field-grouping-and-order.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/ui/public-data-collection.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via /data/{target}/{id} (route, a path literal in a comment in ApprovalService; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-6.mdx (via ApprovalService (symbol, a top-level class))

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.

What this run could not see
  • 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 a18c514965f90e09fa9432c3e72e34426ebc03d6 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 a18c514965f90e09fa9432c3e72e34426ebc03d6 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4437a3219feeef4f52a06d69c9912a14aa2ad7e3
Local-runs: none

Inputs, and nothing else: card #22738 (body; comments 6104517855 claim, 6105465711 dev report), parent card #22661 (body; comments 6096329009 triage, 6101260211 claim, 6102815147 dev report, 6102873261 census split, 6102951996 seat ACCEPT, 6104471901 landing record) and its contract review 6102920614 on PR #22735 as the family's precedent, PR #22766 (body, its 7-file list, the net diff from merge-base 149294c02c to the head — 7 files, +509/−15, byte-matching the file list), and the head's check-runs, read 2026-10-11T04:45Z. Of 33 check-runs: 22 concluded success (Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, filter, Flag docs affected, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance, The card this PR closes must claim this branch, Type Check · source gates / consumer gates / debt ledger), 3 skipped by their own triggers (Build Docs, Console Pin Gate, Packed-tarball smoke), and 8 still in progress at the read (Lint & Repo Gates; Test Core 1–6; Type Check · workspace). This record judges the diff and the concluded set only; the in-progress families conclude on their own and the owning seat reads them before arming — a red there is theirs to act on, not pre-judged here. Security card: classes, positions and functions only; nothing here is a request shape or a recipe.

① Derived judgments

  1. @objectstack/plugin-approvals — accept-set narrowing at ApprovalService.enrichRows, one intake for the inbox list and the inbox item. Read at the head, both public readers of a request row (the list reader and the single-item reader) call enrichRows, and the new gate sits on its referenced-title loop: servesPayloadDisplayTarget(object) is asked once per distinct TARGET object before any id of that target is queried, and a refused target is passed over before the engine is called. No response status changes. Right.
  2. Operation get at both positions. Right — the family's ruling (6102920614, ①.2): each read turns an id the caller holds into the record it names; a list-only target is withheld, a get-only one served, exactly as the data door's $expand and the dataset door's labels answer the same target, so one target gets one answer across four doors. user-type payload fields pass through the same gate and are unchanged: sys_user declares apiEnabled: true with a whitelist that grants get (read at the head).
  3. The withheld answer at the inbox: the key is dropped from payload_display, and the snapshot's stored id stands in payload. Right, and the dev's deviation (a) is the correct reading of the card's direction. payload_display is assembled only from title hits (refTitles), so a deleted or unreadable target already answers no key, and the id in payload is never touched; the family's principle (fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the target object its declared exposure — $expand and the dataset label passes (#22661) #22735 ①.3) is that the new answer takes the shape the old answer already had for an unreadable target. Writing the id INTO payload_display would be a shape no existing branch produces, and would let a renderer that trusts that map show an id as a title. The pin asserts the read set as well as the value (a refused target is not read; its stored id is intact; the served targets keep their titles) for all three refused shapes (the off switch, a whitelist without get, the deny-all whitelist) and both served ones.
  4. @objectstack/plugin-audit — narrowing at resolveLookupTitles, where both branches end. Read at the head, the tracked-change branch (planTrackedLookupReads → renderTrackedChangeSummary) and the fired-milestone branch (planMilestoneTokenReads → renderMilestoneSummary) each hand their read plan to resolveLookupTitles; the gate is the first statement of its per-target loop, ahead of titleFieldOf, so a refused target is never queried under api.sudo(). Right. One gate, both branches.
  5. The withheld answer at the activity summary: the stored id, for rows written from now on; nothing stored is rewritten. Right. An absent title entry makes displayFieldValue render the raw id — the existing best-effort answer for an unresolvable reference, so the shape is old. A sys_activity row is a write-time snapshot; backfilling it would rewrite the audit trail, which is the red line's own object (③), so the no-rewrite choice is the only one that passes without a human. The residual it leaves — rows written before this change keep the titles they were written with — is declared in the changeset and bounded by the dev's producer census (zero in-repo producers of such a row); named in ③.
  6. The recorded fact is intact. The activity row's metadata still carries the raw before/after values, and sys_audit_log's old_value / new_value snapshots are untouched (the compliance row is written unconditionally, outside anything this diff reaches). Only the rendered summary text narrows. Right — this is what makes ①.5 a tightening of what is SERVED and not a loss of what is RECORDED.
  7. Fail-closed branch, both positions. A throwing declaration read is caught inside the decision function, one warn line names the target, and the answer is false (withheld). Right: the shape of servesLabelTarget, and warn is the right level (a functional degradation the reader can see: titles missing; nothing claimed persisted is lost). An engine WITHOUT getSchema serves nothing on either branch: resolveLookupFields answers no fields without a schema, so no referenced id is collected; getFieldDefs answers null without getSchema, so the audit read plan is empty. Verified at the head, so the optional-chained read is not a fail-open. A target the registry does not hold answers no enable block and is "served" by the decision, as servesExpansionTarget answers it — but there is no row to read through the engine either, so the pre-existing nothing stands.
  8. Declaration source. Both gates read engine.getSchema(target), which is ObjectQL.getSchema → this._registry.getObject(name) — the same registry object servesExpansionTarget reads (engine.registry.getObject) and the analytics plugin's declaration provider wraps. In each file it is also the member every other schema read already uses (resolveLookupFields, resolveDisplayField, resolveFieldLabels; getObjectDef, getFieldDefs), so the gate and the resolution it guards cannot disagree about what the target declares. Right.
  9. One decision, no second rule. Both ask canServeApiOperation(enable, 'get'), the boolean face of apiExposureDenialReason (api-derivation.ts); the enumeration pin's DECISION_CALL accepts either face, and its ablation D (a hand-spelled rule) is the dev's red. Right.
  10. Public surface. servesPayloadDisplayTarget is private; servesSummaryTitleTarget, DeclarationReader and WarnSink are module-private; neither package's index.ts is in the diff and audit-writers.ts's export list is unchanged; EnableLike is a type-only import from @objectstack/spec/data, a workspace:* dependency both packages already declare. No new export, no new public type, no new dependency, no new error code, no changed status. No public widening — right.
  11. Enumeration pin. Rows 5 and 6 move open → decided, each with its decision function, its wiring substring (matched over code lines only — the reader strips comment lines, so the docblocks that name the functions cannot satisfy it), its pin path, and the sorted roster of four decision functions. functionBody finds each declaration by the pin's own regex (private servesPayloadDisplayTarget(, function servesSummaryTitleTarget() and reads to the first balanced brace pair: a one-line signature (dev deviation c) is a constraint of that reader, and the failure mode of breaking it is loud (the dev's first run went red), not silent. Rows 4 and 7 keep their open carriers. Right.
  12. Pins. The approvals pin pairs every refused shape with a served control and asserts WHICH objects were read; the audit pin runs through a real ObjectQL with per-object read counters and asserts the same two properties plus a fail-closed leg; the dogfood pin boots the real composition (security, automation, the record-change trigger, approvals, audit), arms on the data door's own get answer per target and on both rows existing at rest, then asserts the inbox list, the inbox item and the activity summary for an administrator and a member. None can pass with the decision bypassed. The ablation ledger (A, A2, B, B2, C, C-appr, D) and the package suites are the dev's measurement; nothing was re-run here (read-only brief). Dev deviation (d) — the approvals double answers a table whole, as check:objectql-double-limit prescribed — does not weaken the pin: with one row per table the value assertions still discriminate, and the property held is the read set. Right.
  13. Scope. In each package the diff is an import line, the decision function (and, in audit, its two type aliases), a docblock sentence and one skip of the loop body; nothing outside the title resolution moves. 7 files, +509/−15, source +68/−6. Right.
  14. Monotonicity. For every target and every caller, what is served after is a subset of what was served before: the only new control flow skips a read; no path turns a withheld answer into a served one; no row, column or write is dropped, suppressed or rewritten. A tightening only — the fact ③ rests on.

② Semver level

  • Changeset 22738-lookup-title-exposure.md: minor for @objectstack/plugin-approvals and @objectstack/plugin-audit, with the ! summary, the **BREAKING** banner, FROM → TO stated per position (including the no-rewrite rule for stored rows and the fail-closed answer), and exactly one ADR-0087 marker (not-required (no-migration-prescription) — no spec key, spelling, export or stored shape moves). Right: an accept-set narrowing is breaking and the launch window ships breaking as minor; the body matches the diff sentence by sentence; Check Changeset concluded success on this head. Both packages publish (17.7.0, not private). The other touched packages publish nothing from this diff: @objectstack/core ships dist only and only a test changed; @objectstack/dogfood is private. So the two named packages are the whole publishing set, and skip-changeset would have been wrong. The shape is the landed security(data, analytics): a lookup target's exposure declaration is not judged when the data door's $expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661 changeset's, with the arm that review prescribed.
  • Clause-②: no (narrowing) on the claim, the PR body and the changeset, verbatim across the three. Value right: no widening — no new export, no new error code, no accept set grows. Arm right: the diff is a narrowing (①.14), and (narrowing) is the spelling the arm exists for. The precedent's one prescription (6102920614, ②) is carried here from the claim onward, so there is nothing to prescribe.

③ Boundary flags

  • open_questions: none (the dev report's list is empty).
  • The services lane's red line (lane file: a loosening of the security and permission boundary is a human floor, always escalated, never decided on a seat's behalf; the boundary includes the audit trail). Judged: this change only tightens, so the floor is not reached. ①.14 (served-after is a subset of served-before, at both positions, for both personas), ①.6 (the recorded fact — raw before/after values in the activity row's metadata, and the compliance row's snapshots — is untouched), ①.5 (no stored row is rewritten). The lane's other red line, zero hold on packages/spec, is honoured: no spec path is in the diff; the decision is imported, not re-spelled.
  • Residual, declared (①.5): activity rows written before this change keep the titles they were written with and remain served by the data door. Bounded by the dev's producer census (ten in-repo objects refuse get; the one lookup into them starts from an object that is itself unexposed — the dev's reading at bf515e724d, not re-measured here; deployed and cloud-held definitions declared NOT MEASURED). A read-side answer on the served row would be a second position with its own card; it is not owed by this claim and would not be a tightening of the trail itself. Not blocking; named for the owning seat.
  • Deviation (a) no payload_display key rather than the id written into it: answered — right (①.3).
  • Deviation (b) line budget over the suggestion: Check PR Size concluded success; the 220-line real-stack pin is the reason, and it is the only instrument that reaches both served doors under a system context. No flag.
  • Deviation (c) one-line decision signature: answered — right (①.11).
  • Deviation (d) the approvals double answers a table whole: answered — right (①.12).
  • Deviation (e) leg C's approvals preflight never ran on the first build and was re-run as C-appr: a process note on the dev's own ablation ledger; the head's check-runs are the gate verdicts. No effect on this record.
  • Deviation (f) origin/main merged once into the branch, closure rebuilt, pin re-run: the net diff read here is against current main's merge-base; the queue leg re-merges on landing. No effect.
  • Deviation (g) 73 gate families derived against 53 dispatched, all run: process note. No effect.
  • Deviation (h) zero label writes, a changeset exists: right (②).
  • Deviation (i) Clause-② copied verbatim: answered in ②.
  • Deviation (j) an earlier fixture draft: process note on the dev's before-run; the committed pin is what was read. No effect.
  • Out-of-scope finding (carrier: none) — ApprovalService.expandPositionUsers filters position holds by organization, and several existing approval dogfood fixtures insert org-less holds; whether any of their assertions depends on the reader being a real approver was NOT measured. Escalated to the dispatching seat as an unmeasured test-fixture question (no product reach is claimed); a measurement to take or decline, not a blocker here.
  • Cross-domain path: declared in the claim before any edit and present on the services seat post ([PM seat] domain:services — 🟢 os-project-manager · session_01CBAfsWMSfM3EToQGVStEcp #6021) naming both functions. Right. No other open PR may claim the same single-writer path concluded success.
  • Closing keyword: Fixes #22738 is right — both census rows the card carries land here (unlike fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the target object its declared exposure — $expand and the dataset label passes (#22661) #22735's Part of); The card this PR closes must claim this branch and Part-of PR must not also close its card concluded success. The parent security(data, analytics): a lookup target's exposure declaration is not judged when the data door's $expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661 stays open on rows 4 and 7.
  • Check-runs in progress (8, named above): the verdict stands on the diff and the concluded set; landing still waits on every required context green per the Post-Task Checklist.
  • Governed surfaces: none of the 7 paths hits a GOVERNED_SURFACES row; Governed Surface Queue Guard concluded success. This record is the contract-tier review the claim's narrowing asked for before the queue, not a Tier S landing record.

Implemented-by: claude/issue-22738-lookup-title-exposure
Reviewed-by: session_01JfJfBUC3cQ6hhgm9MQK76T

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 05:00
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 05:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 896a434 Oct 11, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22738-lookup-title-exposure branch October 11, 2026 05:28
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/l tests tooling

Projects

None yet

2 participants