Skip to content

feat(platform-objects): sys_exchange_rate — an organization's dated exchange rates, admin-written and audited, with a Setup list under Localization - #27246

Draft
objectstack-fleet[bot] wants to merge 10 commits into
mainfrom
claude/project-thread-ozmi9m
Draft

objectstack-fleet[bot] wants to merge 10 commits into
mainfrom
claude/project-thread-ozmi9m

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread
Fixes #27185

⛔ not released alone — pairs with #27192 (R2); merges in the R2 window

Clause-②: no
Metadata-protocol: none

维护者速读

What changes

  • sys_exchange_rate (packages/platform-objects/src/system/sys-exchange-rate.object.ts), managedBy: 'system-data':
    • currency — text, valueDomain: 'iso_4217_currency', required.
    • rate — number (the platform's exact-decimal column, never float), scale: 18, min = 10⁻¹⁸ (strictly positive), required. Why 18, not 12: see Acceptance notes.
    • effective_date — date, required. source — select manual / import / app, default manual.
    • Unique (currency, effective_date) spelled unique: 'organization' (ADR-0120 D1), so two organizations each hold their own EUR rate for a day.
    • No tenancy opt-out: organization_id is injected and the Layer 0 wall scopes it.
    • enable.trackHistory: true plus per-field trackHistory on the four ruled columns; the object is not on plugin-audit's skip list, so every write lands in sys_audit_log.
    • Who writes: organization_admin's '*' grant reaches it (not private, no requiredPermissions); member_default names no business object, so members neither read nor write it (ruling parameter 2).
  • Rate direction stated once: EXCHANGE_RATE_DIRECTION (exported) is the rate field's description and the all_rates list view's description, which the console renders on the list page. EXCHANGE_RATE_SCALE exported beside it.
  • Registration: PlatformObjectsPlugin registers it in its first manifest (project database, no routing triple), beside sys_migration / sys_migration_journal / sys_secret: its reader is the ObjectQL engine's write path, present in every composition.
  • Setup: nav_exchange_rates in group_configuration directly under nav_settings_localization, opening all_rates.
  • Translations: object, fields, options, view, empty state and the nav label in en / zh-CN / ja-JP / es-ES (the three translated locales hand-written; bundles regenerated with check-i18n-bundles.mjs --write, extract config gains the object).
  • Docs: content/docs/ui/setup-app.mdx — the Configuration row and one entry saying how finance records a month's rates.
  • Changeset: .changeset/27185-exchange-rate-table.md, @objectstack/platform-objects patch.

Why

Multi-currency shape C′ (ruling 6465431966 on #27119; epic #8345, M1-1): a record-mode amount is converted to the home currency at write from the rate in force on its document date. This is that rate table. Parameters 2 (admin-only) and 3 (1 home = rate foreign, so home = amount ÷ rate) are applied as ruled; parameter 4 as recorded in Acceptance notes.

Tests

  • New src/system/sys-exchange-rate.object.test.ts (15 cases) pins the declaration each enforcing mechanism reads, on the built value: one unique key on (currency, effective_date) equal to 'organization'; resolveInjectedSystemColumns plans organization_id; not private and no requiredPermissions (the org-admin wildcard reaches it); resolveCrudAffordances gives New/Edit/Delete and no import; trackHistory on object and columns; iso_4217_currency with the write predicate's member / non-member / lowercase answers; rate is number with scale 18 and min 10⁻¹⁸; the direction sentence on field and view; the nav entry sits right after Localization and names a declared view.
  • Updated: plugin.test.ts (registration roster), platform-objects.test.ts (nav target classified as owned and listable), bundle-ownership.test.ts (bundle roster).

Acceptance notes

Verification

Package test + typecheck (lock VERDICT)
head 86e39faf0 (final):
pnpm --filter @objectstack/platform-objects test
  Test Files  97 passed (97)
  Tests  1377 passed (1377)
  os-verify-lock: VERDICT command-exit 0
pnpm --filter @objectstack/platform-objects typecheck
  check:test-typecheck: OK; os-verify-lock: VERDICT command-exit 0
node scripts/check-i18n-bundles.mjs :: exit 0 (platform-objects in sync, 11 bundles)
Derived gates (dispatch-gates --commands, --ran reconciliation)
dispatch-gates --commands: 98 families (90 derived first, +8 after the census artefact joined the diff).
90 run on head faf1b6e3 (before the scale/census commit): 86 exit 0 at first run;
  4 refused/red, then resolved:
  - check-plugin-teardown-shape --self-test :: exit 3 (shallow clone) -> fetched pinned commit -> exit 0
  - spec check:skill-examples :: exit 3 (client-react unbuilt) -> built -> exit 0
  - check:dual-build-cjs-loads :: exit 3 (unbuilt packages) -> exit 0 after the build
  - check:platform-object-tenancy-census :: exit 1 (census drift, new object) -> --write -> exit 0
On head 86e39faf0: the 8 new families :: exit 0; re-run on the final tree, all exit 0:
  check:platform-object-tenancy-census, check:i18n (bundles), check:keyed-text-bounds,
  check-changeset-no-major, check-empty-changeset, check:nul-bytes, check:org-identifier,
  check:system-context-census, check:issue-citations, check:doc-authoring, check:i18n-stale-fill
dispatch-gates --ran: 98 derived famil(ies) accounted for - 98 run, 0 NOT-MEASURED
  (a DERIVED zero - all 98 recorded an exit code and none of them is 3).
Not run locally (CI's): pnpm lint, test:repo, the full farm.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint is CI's.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

Generated by Claude Code

…up list under Localization (WIP)

Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf
Co-authored-by: Claude <noreply@anthropic.com>
…arget classification (WIP)

Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf
Co-authored-by: Claude <noreply@anthropic.com>
…, its list and the Setup entry

Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf
Co-authored-by: Claude <noreply@anthropic.com>
…nancy census counts it

Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf
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 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/spec, touching 28 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/platform-objects/src/system/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

48 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 9ff8154cf694c616de5d467b35af21e037de1509.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/platform-objects/src/system/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: isSystem (5 routes)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 56 of 208 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 — 148 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 9ff8154cf694c616de5d467b35af21e037de1509 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9ff8154cf694c616de5d467b35af21e037de1509

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

…ency ADR by its card, not an unwritten number

Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf
Co-authored-by: Claude <noreply@anthropic.com>
… 24 (ruling 6469382727)

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

Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf
Co-authored-by: Claude <noreply@anthropic.com>
The new tenant-scoped platform table carries organization_id, so
`os migrate organization-ownership` refused every plan as carrying an
uninventoried platform table. It takes fate 3 (attribution, no anchors:
the Default Organization under single, otherwise reported), as the other
organization-authored tables added since the first census do.

Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf
Co-authored-by: Claude <noreply@anthropic.com>
SETUP_NAV_CONTRIBUTIONS gains nav_exchange_rates, so the composition's
Setup tree grows from 31 entries to 32.

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

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 3e26252e655f21fb2e990efc111c16e383e38774 · 2026-10-28T14:58Z

Triage seat, under gap rule (b) (#27062): the PR has been open since 12:33Z with no record or claim on any head (named as due in R731). The review runs at CONTRACT_REVIEW_TIER. If the lane claims or posts a record on this head first, triage withdraws.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3e26252e655f21fb2e990efc111c16e383e38774
Local-runs: none
Scope: packages/spec/src/system/constants/platform-object-names.ts + packages/spec/src/system/constants/platform-object-names.test.ts, packages/platform-objects/src/system/sys-exchange-rate.object.ts, packages/platform-objects/src/plugin.ts, packages/platform-objects/src/plugin.test.ts, packages/cli/src/utils/organization-ownership-inventory.ts, packages/cli/src/utils/organization-ownership-inventory.test.ts, packages/objectql/src/engine-lifecycle-health.ts, packages/lint/src/validate-object-references.ts, packages/lint/src/validate-nav-access.ts, packages/spec/src/stack.zod.ts

Governing shape: ADR-0144 (PR #27239, head 775d8c09, not yet on main) D3, D7 and Open items 7 and 11; ruling 6465431966 (#27119) and ruling 6469382727 (#27185: rate column decimal scale 12, total precision 24, never float). The tree was judged, not the PR body; where the two differ it is said below.

① Derived judgments

  • Registry entry — sys_exchange_rate added to PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'], sorted between sys_email_template and sys_identity_email. RIGHT, and forced: the spec ratchet (platform-object-names.test.ts) scans every packages/**/*.object.ts, derives the owner from the path segment before src (packages/platform-objects/src/system/sys-exchange-rate.object.ts → platform-objects) and asserts set equality per group; without this hunk the ratchet reds on the new object file.
  • Accept-set widening at lint — isPlatformProvidedObjectName('sys_exchange_rate') now answers true, so a stack reference to it is admitted at rung ③ (validate-object-references.ts:238, validate-translation-references.ts:1848, stack.zod.ts:4581 action objectName) and validate-nav-access.ts:162 skips the grant check for a nav item naming it ("granted by its own package"). RIGHT: PlatformObjectsPlugin registers the object unconditionally in its first manifest (plugin.ts:198, pinned by plugin.test.ts), so what lint admits resolves at runtime wherever platform-objects composes; the nav skip is right because the object's own grant is the organization_admin wildcard (not access.default: 'private', no requiredPermissions), the same posture as the other object-type entries in group_configuration.
  • Runtime consumer — engine-lifecycle-health.ts#resolvePrimaryDatasource iterates PLATFORM_PROVIDED_OBJECT_NAMES; sys_exchange_rate now votes as a witness when registered. RIGHT: it declares no lifecycle.class (not a system-ledger object) and rides the first manifest with no routing triple exactly like sys_migration / sys_migration_journal / sys_secret, so it lands on their datasource and cannot by itself produce system-objects-split.
  • @objectstack/cli inventory — organization-ownership-inventory.test.ts:123 requires a row for every registered name; the diff adds sys_exchange_rate (census PMAIN, fate attribution, no anchors). RIGHT; the cli patch in the changeset follows from it.
  • Tenancy census — scripts/platform-object-tenancy-census.json 87→88 registered, 44→45 in reach, tenantField: organization_id. RIGHT, derived artefact consistent with the hunk and with the object's no-opt-out tenancy.
  • Cloud repo at the next pin move — service-cloud control-plane-fate-supplement.test.ts filters its supplement against the platform set (an added name changes nothing) and objectos-runtime tenant-v18-upgrade-retired-tables.test.ts only asserts retired names are absent. RIGHT: additive, neither breaks.
  • No new spec export — api-surface/system.json and export-origins/system.json unchanged; dangling-reference-audit.ts reads only the plugin-security group. RIGHT, unaffected.
  • Published surface outside hits (lane's ACCEPT) — @objectstack/platform-objects gains SysExchangeRate, EXCHANGE_RATE_DIRECTION, EXCHANGE_RATE_SCALE, EXCHANGE_RATE_PRECISION (system/index.ts), a Setup nav entry and four locales. Read for ②, not reviewed here.
  • Wrong: none found in the hits hunk.

② Semver level

  • Changeset .changeset/27185-exchange-rate-table.md: @objectstack/platform-objects: patch, @objectstack/spec: patch, @objectstack/cli: patch; body carries Clause-②: no and Metadata-protocol: none. Those three packages are exactly the released packages the diff moves (spec: registry membership; cli: inventory row; platform-objects: the object and its exports).
  • Metadata-protocol: none is right: sys_exchange_rate is a system object an author cannot write (ADR-0029 D4), no authorable key, value or type changes. AGENTS.md Post-Task Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3: "none takes patch" — so patch on all three is the rule's level. Precedent in packages/spec/CHANGELOG.md is split (17.2.0 sys_flow_dispatch under Patch Changes; 17.7.0 sys_flow_credential and sys_comment_reaction under Minor), the written rule governs. Clause-②: no matches the card's own declaration (Clause-②: no · Metadata-protocol: none) and appears line-initial in both PR body and changeset. Clause-②: no — matches the diff.
  • The PR body's "Changeset: @objectstack/platform-objects patch" names one of the three bumps; the changeset itself is complete. Body correction only (③).

③ Boundary flags

④ Design judgment

packages/spec/src/system/constants/platform-object-names.ts (schema face: the curated platform object registry, read by lint, the engine and the cli).

  • Against the code it cites. The file's own maintenance contract ("adding an object to a platform package means adding its name here; the owning package's conformance test fails otherwise") is enforced by platform-object-names.test.ts, which derives the owning group from the file path and compares sets. The hunk adds the one name that test now finds in packages/platform-objects/src/system/sys-exchange-rate.object.ts (ObjectSchema.create({ name: 'sys_exchange_rate' … })), under the group that path yields, in sorted position. HOLDS.
  • Against its first consumer (lint rung ③ and the nav-access skip). Tried to break it: (i) a composition without platform-objects — then every name in that group is equally absent, which is the registry's stated contract ("registered by a package in this repo"), identical to sys_user; (ii) a composition with platform-objects — the object registers unconditionally in the first manifest, so a lint-admitted reference resolves; (iii) the engine's primary-datasource vote — the new witness lands with its three manifest siblings; (iv) the cloud repo's two registry readers — additive and tolerant. No reader is made wrong by the entry. HOLDS.
  • Against the ADR it implements (ADR-0144 D3, Open items 7 and 11). D3 names sys_exchange_rate { currency, rate, effective_date, source } as a platform system object; the registry names it under the package ADR-0144's M1-1 row and the epic territory name. Open item 7 (uniqueness and audit) is answered in the object the entry points at: one unique key (currency, effective_date) spelled unique: 'organization' (ADR-0120 D1), trackHistory on object and the four columns, not on plugin-audit's skip list. Open item 11 is answered by placement, see ③. HOLDS; the settlement wants writing down (should-fix, ③).
  • Against ruling 6469382727. The hits file carries no numeric claim; the object it names declares number / scale 12 / precision 24 / min 1e-12, and the engine refuses rather than rounds at both seams. The PR body's scale 18 account is the only disagreement, and it is the body that is wrong. HOLDS.
  • Must-fix: none.
  • Should-fix: (1) PR body stale against the tree — scale 18 → scale 12 / precision 24, Human Minimum Add metamodel interfaces for ObjectQL/ObjectUI contract #1/Migrate from npm to pnpm #6 and the changeset line must name the packages/spec and packages/cli edits; (2) the ADR-0029 D1/D3/D6 ownership settlement is stated nowhere the ADR can read it — state it on the card so docs(adr): ADR-0144 multi-currency — record currency, dated rates, write-time home projection; dated note on ADR-0104 #27239 amends Open item 11; (3) [lane seat] the setup-app.mdx hunk conflicts with main's deletion of that page — rebase and move the docs line to content/docs/ui/apps.mdx.

Implemented-by: claude/project-thread-ozmi9m
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

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

documentation Improvements or additions to documentation protocol:system size/l tests tooling

Projects

None yet

2 participants