Repository navigation
实现 ADR-0106:元数据面 FLS——object schema 按调用者掩码(#3661 ③ 落地) #3682
Description
Activity
存量裁决轮(维护者 2026-08-06 委托,session_01LeEfA7CFwbJb7JJmXm2KM3):裁定入队。ADR-0106 已合入而实现零落地(rest/runtime 的 meta 路径对
getReadableFields零调用),受限调用者仍能从 /meta 拿到不可读字段全量 schema——已裁决未执行的信息披露缺口,restore-invariant 类。派发时注意与 #4513 同触 meta 读路径,需串行或协调。维护者可否决。
Generated by Claude Code
迁移:
domain:engine-core→domain:metadata(维护者 2026-08-07 批准拆分;座位贴 #6367,SKILL PR #6370)。落点证据:GET /meta/object 按调用者掩码,mask-after-cache ——/meta服务路径(rest-server.ts + runtime/domains/meta.ts)。全量分类审计 49 卡三判,本卡 MOVE/high。分诊会话:session_01BickTBKm2JYSNnrtPT8ysa。
Generated by Claude Code
Claim: PM loop round 3 (
domain:metadataseat, sticker #6367)
Session (PM):session_01KDU3qAuJyajAQm3GkUXdfA
Branch:claude/issue-3682-adr-0106-meta-fls
Execution:mode:cloudsingle-container card (size L per the 2026-08-07 ruling on heavy cards) — its own cloud session with a fresh clone; the dev session's identity will appear on its PR and report comment.
Domain:domain:metadata(triage MOVE verdict 2026-08-07: mask-after-cache on the /meta serving path)
File surface:packages/rest/src/rest-server.ts(GET /meta object single — cached + non-cached — and list reads: D1/D2 projection, D3 mask-after-cache + fingerprint ETag),packages/runtime/src/domains/meta.ts(/metadata catch-all object branch),packages/plugins/plugin-security(D7 fallback permission set), tests, deployment-docs CDN note, ADR-0106 status flip. Stop on breach; explain in the report.
Serial constraints cleared: predecessor on this seam #4513 → PR #6561 MERGED 2026-08-08 05:19Z (this card's byte-identical-baseline clause now measures against a consistent read surface); #6215 / #5309 in flight this seat — metadata-protocol/metadata packages, no file overlap;domain:clilane currently has no in-flight claim onrest-server.ts(its round-3 card is packages/client) — its queued pool cards #6349/#5582/#6306 do target this file, courtesy notice posted on the cli seat sticker #6024 so sequencing is visible before they dispatch.
Container class: L —mode:cloud.
Generated by Claude Code
{ "issue": "objectstack-ai/objectstack#3682", "status": "done", "branch": "claude/issue-3682-adr-0106-meta-fls", "pr": "https://github.com/objectstack-ai/objectstack/pull/6612", "premise_still_valid": true, "premise_evidence": "Re-verified against origin/main (1a53a02) before writing code: `grep -rn getReadableFields packages/rest/src packages/runtime/src` returned exactly one non-test call site — the #3547 export-column projection at rest-server.ts:6169. Zero on any /meta or /metadata read path. The 2026-08-06 adjudication's 'implementation is at ZERO' held.", "summary": "ADR-0106 D1-D8 implemented. One shared projection lives in @objectstack/metadata-core (object-schema-fls.ts, following #6561's per-exit shared-normalizer shape) and is called at every object-schema exit: REST single cached, single uncached, ?layers=true (code/overlay/effective), compound-name, and list; runtime /metadata object branch (protocol + registry + last-ditch), /metadata/objects list (protocol + registry), and the legacy one-segment read. D3 masks after the cache and folds a denied-set FNV-1a fingerprint into the ETag (empty for unrestricted callers -> byte-identical); when a projection applies, If-None-Match is NOT forwarded to getMetaItemCached (its validator hashes the unfiltered document) and the comparison happens in the REST layer against the fingerprinted ETag. D4 exempts isSystem + studio.access/setup.access, short-circuiting before the security call. D6 three tiers, with 503 FIELD_VISIBILITY_UNRESOLVED on a throw and on an all-denied projection. D7 adds SecurityPlugin.getMetadataReadableFields (fallback-set resolution on zero permission sets); getReadableFields unchanged. D8 ships metadata.maskObjectFields plus OS_ALLOW_UNMASKED_OBJECT_METADATA. Docs: env-var entry + a new CDN/reverse-proxy section in production-readiness. ADR-0106 Status flipped Proposed -> Accepted, with two adr-anchors entries. Changeset: minor on metadata-core, rest, runtime, plugin-security. metadata-protocol signatures untouched; objectui untouched; packages/spec untouched; content/docs/releases untouched; the metadata WRITE path untouched.", "tests": { "shared_case_table": "packages/metadata-core/src/object-schema-fls-contract.ts, exported via @objectstack/metadata-core/testing. 11 cases drive every exit in both dispatch packages, so a future exit that forgets the projection fails BY NAME. The absence assertion also fails if the field name appears anywhere in the serialized document, so a partial redaction cannot pass.", "commands_and_output": [ "pnpm --filter @objectstack/rest exec vitest run meta-object-fls -> Test Files 1 passed (1) | Tests 88 passed (88)", "pnpm --filter @objectstack/runtime exec vitest run meta-object-fls -> Test Files 1 passed (1) | Tests 67 passed (67)", "pnpm --filter @objectstack/metadata-core exec vitest run object-schema-fls -> Test Files 1 passed (1) | Tests 21 passed (21)", "pnpm --filter @objectstack/plugin-security exec vitest run get-metadata-readable-fields -> Test Files 1 passed (1) | Tests 7 passed (7)", "pnpm exec turbo run test --concurrency=4 -> Tasks: 135 successful, 135 total | Time: 21m21.76s", "post-merge touched packages: metadata-core 124 passed (9 files) | plugin-security 780 passed (37 files) | rest 1027 passed (67 files) | runtime 1678 passed (112 files)", "pnpm exec turbo run typecheck --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*' -> Tasks: 120 successful, 120 total", "pnpm lint -> clean", "pnpm --filter @objectstack/spec check:generated -> All 10 generated artifacts are up to date." ], "note_on_a_misleading_first_run": "The first `pnpm test` (full turbo concurrency) reported four packages failing in 21 seconds. Every one of them passed in isolation; re-running with --concurrency=4 gave 135/135 green in 21 minutes. It was machine resource contention, not a real failure — recorded here because the raw log looks like four red packages.", "gates": "Enumerated from .github/workflows/lint.yml and run one by one, not from memory: the ESLint job's 22 check:* steps (slot-lookup, query-options-erasure, verify-stand-in, nul-bytes, doc-authoring, docs-audit-scope, role-word, quick-reference-counts, adr-anchors, org-identifier, authz-resolver, service-providers, route-envelope, error-code-casing, wildcard-fallthrough, meta-type-normalized, init-service-contract, durability-log-level, startup-registry-verdict, objectui-changeset, release-notes, release-body, node-version, workflow-status-functions, shard-attestation, published-files, engine-double-contract, resume-authority-declared, merge-driver, spec-parsed-alias) and the TypeScript Type Check job's full sequence (type-check-coverage, driver-conformance, stall-guard, spec tsc --noEmit, spec's twelve check:* gates, skill-frame-sync, skill-compatibility, build, full typecheck, type-check-debt, examples typecheck, downstream-contract typecheck, doc-formula-expressions, i18n, i18n-coverage). ALL GREEN. check:nul-bytes caught one real slip on the first pass — the fingerprint separator had been written as a raw NUL byte rather than the escape text; fixed in its own commit (a5839f2).", "reverse_verification": { "method": "Disabled the projection (applyObjectSchemaMask returns its input for every posture) and re-ran both dispatch suites. Directions predicted in writing BEFORE running.", "predicted": "rest: 28 contract failures (the 4 projection-dependent rows x 7 exit groups) + 2 ETag failures + 1 empty-projection failure = 31. runtime: 4 projection-dependent rows x 6 resolvers = 24. Staying GREEN: both D4 exemption rows, all three D6 posture rows, the unrestricted-caller row, the D8 opt-out row.", "measured": "rest: 31 failed / 57 passed. runtime: 24 failed / 43 passed. The named guard rails all stayed green.", "verdict": "Exact match on both counts and on the direction of every guard rail. Projection restored, re-run: 88/67 green. No measurement contradicted a prediction." } }, "mandatory_questions": { "q1_effect_on_6190_and_5079": "Both UNAFFECTED — measured, not assumed. #6190 (org flow overlay cold-boot unbind, narrowed) is about WHICH overlay row binds at boot; #5079 (a deleted overlay still listed in /meta) is about WHETHER an item appears in the listing at all. This change touches neither: it operates strictly on the `fields` record of a document an exit has ALREADY decided to serve, and it is a no-op for any document without a `fields` record. It neither adds nor removes list entries (the list exit projects each item in place and never filters), and it never changes which resolver answers. Same reasoning for #6562 (overlay-backed vs registry-backed reads answering different field sets): the mask layers on top of whatever the exit serves, so #6562's divergence survives underneath it unchanged — I did not try to fix it here. The one place they touch is a note in the runtime suite: masking now runs identically on both of #6562's paths, so whoever fixes it gets the projection for free on the converged path.", "q2_d5_sweep": "Yes — three uncovered schema-bearing outlets found, all searched-first and filed unassigned with the `finding` label: #6603 (the masked-read -> PUT round-trip, see open_questions), #6599 (/meta/_drafts and /metadata/_drafts serve draft object schemas unmasked), #6601 (a section-less public form publishes every non-server-managed field of its target object to ANONYMOUS callers, because `allowed.size === 0` falls through to all fields). Checked and confirmed NOT outlets: /openapi.json (its own comment says 'object, shape not described here' — it never enumerates fields), OData $metadata / describe-style surfaces (no such route exists in this repo; odata.zod.ts is a query-adapter spec only), the client SDK describe calls (they proxy REST and inherit the mask), and /data/:object/export (already projects columns via #3547). Three outlets the ADR's D5 enumeration did NOT name were found and are covered in this PR rather than filed: the ?layers=true diagnostic view (three full schemas behind a query flag), the compound-name read, and the runtime's legacy one-segment /metadata/:objectName read." }, "open_questions": [ { "id": "put-round-trip", "severity": "high — read before merging", "question": "ADR-0106 is SILENT on the masked-read -> PUT round-trip, and the interaction is real: PUT /meta/:type/:name carries NO capability gate (only enforceAuth — the neighbouring _migrate route's own comment says so in as many words), so a restricted caller who GETs a masked schema, edits a label, and PUTs it back DELETES the fields that were masked out of their read. Before this change they round-tripped the full schema, so the masking is what creates the hazard.", "action_taken": "Per the ruling, I did NOT invent an answer: the write path is untouched. Filed as #6603 with the four dispositions spelled out (merge-don't-replace / refuse the write / detect-and-409 / accept the loss).", "recommendation": { "correctness": "Refuse the write — require `manage_metadata` on PUT /meta/:type/:name, as the _migrate route already does. Then 'the caller who can write an object schema is the caller who sees all of it' becomes an invariant rather than a coincidence, and the round-trip hazard disappears rather than being detected.", "cost": "Smallest of the four (one gate, one test). Merge-don't-replace is the largest and changes PUT semantics for every other caller and every other type.", "risk": "It is an access change beyond ADR-0106's scope, so it needs the maintainer's call or an ADR-0106 addendum — which is exactly why it is not in this PR." } }, { "id": "d8-spec-seat", "severity": "medium", "question": "D8's config key `metadata.maskObjectFields` needs a declared seat on `MetadataEndpointsConfigSchema` in packages/spec — which was off this task's file surface.", "action_taken": "Checked first whether the config is accepted without a spec edit, as instructed: it is. `normalizeConfig` reads the raw RestServerConfig object and already reads two undeclared keys the same way (`(api as any).enableOpenApi`, `(api as any).enableSearch`), so `(metadata as any).maskObjectFields` works at runtime today. I shipped the escape hatch through that precedent rather than shipping enforcement with no way out, and added `OS_ALLOW_UNMASKED_OBJECT_METADATA` (Prime Directive #9's OS_ALLOW_{X} shape) as the deployment-wide knob — which the runtime /metadata dispatcher needs anyway, having no REST config to read.", "recommendation": "A follow-up spec PR should declare `maskObjectFields: z.boolean().default(true)` on MetadataEndpointsConfigSchema so the key is type-safe in objectstack.config.ts. Honoured-but-undeclared is the debt AGENTS.md names; it is deliberate here only because the surface forbade the fix. I did not file this as an issue since it is squarely this PR's follow-through — say the word and I will." }, { "id": "isecurityservice-seat", "severity": "medium", "question": "D7's `getMetadataReadableFields` is registered as an explicit `Object.assign` extension of the typed `ISecurityService` literal rather than declared on the interface, because `packages/spec/src/contracts/security-service.ts` was off surface.", "action_taken": "Registered as a visible extension (not a silent cast at the call site) with a comment naming why; the dispatch layer feature-detects it and falls back to `getReadableFields`, which is the contract's own documented degradation rule.", "recommendation": "Declare it on ISecurityService in the same follow-up spec PR as the D8 key — one spec change closes both." }, { "id": "empty-readable-set", "severity": "low — flagged as an interpretation", "question": "What should an exit answer when `getReadableFields` legitimately returns `[]`? D6's 'never an empty-fields 200' is written inside the THROW tier's row, so the ADR does not literally rule on this case.", "action_taken": "Treated as the error tier (503). `[]` is only ever produced where plugin-security's own posture read failed closed (#3545), so it is a degraded answer wearing a valid shape — and an empty-fields 200 is exactly what D6 calls 'the worst option: silently wrong UI AND cacheable poison'. Pinned in both dispatch suites.", "recommendation": "Keep as implemented. If a reviewer prefers `fields: {}` for a genuinely all-denied permission set, it is a one-line change in `applyObjectSchemaMask`'s `emptied` flag and one case-table row." } ], "out_of_scope_findings": [ "#6603 — masked read -> PUT round-trip deletes the masked-out fields (filed, unassigned, `finding`)", "#6599 — /meta/_drafts and /metadata/_drafts serve draft object schemas unmasked (filed, unassigned, `finding`)", "#6601 — a section-less public form publishes every non-server-managed field of its object to anonymous callers (filed, unassigned, `finding`)" ], "pm_assumptions_checked": { "same_day_churn": "CONFIRMED and it changed the work. #5895's envelope convergence means `translateMetaEnvelope` is the single rebuild point, so the issue's 'cached 与非 cached 两条路径' wording is stale as a description of the code — the two branches still differ in HOW they reach a document, and both are masked, but the sweep found three more outlets on the same route that neither the issue nor ADR-0106 D5 names (?layers=true, the compound-name read, and the runtime's legacy one-segment read). All three are covered.", "pr_6561": "CONFIRMED on both counts. The byte-identical baseline is measured against post-#6561 main (governServedItem -> applyAuditFieldGovernance runs per exit in metadata-protocol, underneath the mask). Its shared-normalizer pattern is the shape I followed: one pure function in @objectstack/metadata-core plus one case table driving every exit, exactly as protocol.audit-field-governance.test.ts does.", "file_surface": "Held, with one deliberate extension I am flagging rather than burying: the shared normalizer and its case table are NEW files in `packages/metadata-core` (plus one dependency line in packages/rest/package.json), which the surface did not list. The alternative was five copies of the projection across two packages — the exact drift #6561, #5619 and #4513 were each written to prevent, and the thing that makes ADR-0106 D5 false in practice. metadata-core depends only on { spec, zod }, so there is no new edge and no cycle. Nothing else moved: no packages/spec, no objectui, no content/docs/releases, no metadata-protocol signature.", "no_new_fake_engines": "None of the four new suites introduces a fake engine — the REST suites use protocol doubles and the runtime suites use service doubles, so `assertEngineDeleteDispatch` has no call site here. `pnpm check:engine-double-contract` is green." } }
Generated by Claude Code
ACCEPT (PM review,
domain:metadataseat, sessionsession_01KDU3qAuJyajAQm3GkUXdfA) — PR #6612, now ready + queued.Review verified against GitHub: 18 files; every declared surface present (rest meta exits, runtime object branch, plugin-security D7, docs CDN note, ADR status flip + anchors, minor changeset × 4) and the one flagged extension — the shared projection + case table in
@objectstack/metadata-corewith a single newrest → metadata-coredependency edge — accepted: it follows the #6561/#5619 shared-normalizer criterion exactly, and five copies across two packages is the drift D5 exists to prevent.packages/spec, objectui, metadata-protocol signatures, releases/ all untouched as required. ESLint and TypeScript Type Checkcompleted: success, 26 checks zero failures (incl. Validate Package Dependencies for the new edge). Reverse verification matched its written predictions exactly (rest 31 red / runtime 24 red with guard rails green). D5 sweep delivered: three unnamed outlets covered in-PR (?layers=true, compound-name, legacy one-segment) and three findings filed (#6599, #6601, #6603).Sequencing judgment on #6603 (masked GET → PUT deletes masked fields), stated for the veto window rather than asked as permission: this PR lands ahead of #6603's resolution. Rationale: PUT
/meta/:type/:nameis enforceAuth-only today, so any authenticated caller could already clobber schemas arbitrarily — the mask converts a malicious capability into an innocent hazard, it does not create write access; the realistic schema editors (admin / isSystem) are D4-exempt, so their round-trips read unmasked and stay lossless; and the shipped defect this PR fixes is a ruled v17 information-disclosure gap. #6603 is being escalated to the decision inbox now with the dev's recommendation (requiremanage_metadataon the PUT, closing both the pre-existing clobber hole and the round-trip hazard in one gate). The maintainer can stop this landing by closing the queue entry.Also recorded: the D8 config key and the
ISecurityService.getMetadataReadableFieldsmember are honoured-but-undeclared (precedented, deliberate — spec was off-surface); the follow-up spec transfer card is being filed by this seat per rule 3. The issue closes on merge via theFixesline; #3661 ③ is thereby delivered.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
跟踪 ADR-0106(#3678 已合并)的实现。决策背景与完整论证见 ADR 正文及 #3661;客户端侧 ①② 已由 objectstack-ai/objectui#2866 承担。
实现清单(按 ADR 决策点)
security.getReadableFields(object, ctx)投影fields,掩码字段整体删除。协议层getMetaItem/getMetaItems签名不动。落点:packages/rest/src/rest-server.ts—GET /meta/object/:name的 cached 与非 cached 两条路径packages/rest/src/rest-server.ts—GET /meta/object列表读(逐 item 投影)packages/runtime/src/domains/meta.ts—/metadatacatch-all 的 object 分支(protocol 与 registry 两条查找路径)isSystem(getReadableFields 已有)+ 平台 admin(复用 app 过滤的systemPermissions判定)。豁免是 caller 属性,不是路由属性。$metadata/describe 类、client SDK describe);未覆盖的按 Prime Directive chore: version packages #10 开 issue。undefined→ 放行 + 结构化 warn + 指标 +Cache-Control: private, no-store(不发共享 ETag);评估抛异常 → 5xx(绝不放出未掩码体,绝不返回空 fields 的 200)。/auth/me/permissions同款 fallback set 解析(security.fallbackPermissionSet,默认member_default),落plugin-security。metadata.maskObjectFields(命名实现时定),默认开,随当前 major 发布。Proposed→Accepted。验收标准
对一个持有受限权限集的已认证调用者:任一
/meta//metadata出口返回的 object schema 中,其不可读字段(含readable:false与缺失requiredPermissions两种成因)完全不存在;无限制调用者的响应与实现前字节一致;权限变更后旧 ETag 不再命中 304。关联:#3661(③)、#3678(ADR)、objectstack-ai/objectui#2866(①②)、#3547(
getReadableFields)、ADR-0049 / ADR-0066 D3 / ADR-0090。