diff --git a/.changeset/30804-namespace-prefix-platform-requested-names.md b/.changeset/30804-namespace-prefix-platform-requested-names.md new file mode 100644 index 00000000000..7031404e5fa --- /dev/null +++ b/.changeset/30804-namespace-prefix-platform-requested-names.md @@ -0,0 +1,10 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os lint`'s `naming/namespace-prefix` no longer asks a package to rename the `guest_portal` permission set + +Clause-②: no +Metadata-protocol: none + +The public form doors' submit context requests the permission set named exactly `guest_portal`, and the result masker resolves a set registered under that name. A package that declares it is plugging into that door. Renaming it to `_guest_portal`, which the warning used to suggest, would leave the masker with nothing to find. `os lint` now leaves that one name alone in `permissions`. The same spelling in any other collection, or any other permission set name, is still judged. diff --git a/content/docs/ui/doc-pages.mdx b/content/docs/ui/doc-pages.mdx index fa8e86f55e1..8a3c15aa67f 100644 --- a/content/docs/ui/doc-pages.mdx +++ b/content/docs/ui/doc-pages.mdx @@ -74,10 +74,12 @@ src/docs/crm_user_guide.md ✅ package namespace is "crm" src/docs/user_guide.md ❌ bare name — lint error (docs/namespace-prefix) ``` -This is the same `namespace-prefix` lint the platform applies to other -named metadata for readable, globally-unique filenames. It is no longer -**load-bearing for uniqueness**, however: per **ADR-0048**, doc -resolution is **package-scoped**. Packaged items are stored under a +Docs have their own rule id, but the contract is the one `os lint`'s +`naming/namespace-prefix` warning applies to every other package-authored +name: each one carries its package namespace, so that no two installed +packages hold the same name. The runtime does not rely on the prefix to +keep docs apart, however: per **ADR-0048**, doc resolution is +**package-scoped**. Packaged items are stored under a composite `:` registry key, and each retains its `_packageId`, so two installed packages may each ship a doc with the same bare name and coexist — neither silently overwrites the other. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 1161a4e214d..d224294673c 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -847,7 +847,7 @@ "requires": [ "showcase_contributor.fields FLS on showcase_project budget figures (permission-sets.ts — keys are . qualified; bare keys silently enforce nothing, the compile-time lint security-fls-unqualified-key guards that)", "a seeded showcase_project row with a non-null budget", - "showcase_client_liaison — the STOCK readable:false grant since #9308 fixture 4 (permission-sets.ts): showcase_project { allowRead: true } plus fields { 'showcase_project.budget' | '.spent' | '.budget_remaining': { readable: false, editable: false } }, bound to the client_liaison position. Grant it to a FRESH member and to nobody who also holds showcase_contributor: field permissions merge most-permissive across the sets a caller holds, so contributor's readable:true would legitimately un-mask all three" + "showcase_client_liaison — the STOCK readable:false grant since #9308 fixture 4 (permission-sets.ts): showcase_project { allowRead: true } plus fields { 'showcase_project.budget' | '.spent' | '.budget_remaining': { readable: false, editable: false } }, bound to the showcase_client_liaison position. Grant it to a FRESH member and to nobody who also holds showcase_contributor: field permissions merge most-permissive across the sets a caller holds, so contributor's readable:true would legitimately un-mask all three" ], "knownGaps": [ "[CLOSED — API half by #9481, the seed gap itself by #9308 fixture 4; the gap text stays because it carries the reason] stock showcase authored NO readable:false FLS grant, so the read-MASKING half (plugin-security/src/field-masker.ts) had no stock fixture and a run had to author a scratch permission set at runtime — which the SERVER half could do (showcase-fls-read-mask-strip.dogfood.test.ts does exactly that) but the UI half could not, because a console render cannot be driven off a permission set that exists only inside a test's stack. showcase_client_liaison is now that grant on stock seeds, so clause 5 is runnable end to end: grant the set to a fresh member, sign in as them, and compare their API body and their console render against the admin's. packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts pins the server half of the stock grant (by id, in lists, through an explicit $select) with the admin as the entitled contrast.", @@ -1232,7 +1232,7 @@ ], "knownGaps": [ "the owner-based rule is [experimental] and skipped per the liveness ledger — exclude it; criteria + position/unit audiences are the live surface", - "the POSITION-audience variant is CONFOUNDED on stock and scores no widening clause. The only seeded position rule, share_key_account_qualified_contacts_with_managers, shares Northwind-qualified showcase_contact rows with the manager position, and manager's showcase_manager set reads showcase_contact at readScope 'org'. Measured on main c91af8ed6: a manager-holding member reads all 32 contacts, and POST /api/v1/security/explain answers visible, decidedBy 'depth' on a matching AND a non-matching contact — the sharing layer never decides. So acceptance[0]-[2] are scored on the unit_and_subordinates variant (share_new_inquiries_with_field_ops: a member of bu_field_ops or a descendant unit reads exactly the status='new' inquiry, and its sys_record_share row carries source 'rule'); the position variant records its depth-decided reads as the confound. A position audience without org depth is authored in-run by access-security.sharing-rule-authoring-ui" + "the POSITION-audience variant is CONFOUNDED on stock and scores no widening clause. The only seeded position rule, showcase_share_key_account_qualified_contacts_with_managers, shares Northwind-qualified showcase_contact rows with the showcase_manager position, and manager's showcase_manager set reads showcase_contact at readScope 'org'. Measured on main c91af8ed6: a manager-holding member reads all 32 contacts, and POST /api/v1/security/explain answers visible, decidedBy 'depth' on a matching AND a non-matching contact — the sharing layer never decides. So acceptance[0]-[2] are scored on the unit_and_subordinates variant (showcase_share_new_inquiries_with_field_ops: a member of bu_field_ops or a descendant unit reads exactly the status='new' inquiry, and its sys_record_share row carries source 'rule'); the position variant records its depth-decided reads as the confound. A position audience without org depth is authored in-run by access-security.sharing-rule-authoring-ui" ] }, "variants": [ @@ -1241,13 +1241,13 @@ "manual one-off share via the record-shares route" ], "steps": [ - "boot the showcase on its own port and file DB (dogfood §0's isolation, RUNNER's canonical boot line) in the stock `single` tenancy posture: no clause needs OS_TENANCY_POSTURE=isolated or any walled boot; add the audience member to bu_west_coast through sys_business_unit_member (fixtures.requires); give a second member the manager position to record the position confound", - "the probes are seeded: share_new_inquiries_with_field_ops matches status='new', and the seed ships one new, one contacted and one closed showcase_inquiry (OWD private). Use the new one as the MATCHING probe and a non-new one as the NON-MATCHING probe", + "boot the showcase on its own port and file DB (dogfood §0's isolation, RUNNER's canonical boot line) in the stock `single` tenancy posture: no clause needs OS_TENANCY_POSTURE=isolated or any walled boot; add the audience member to bu_west_coast through sys_business_unit_member (fixtures.requires); give a second member the showcase_manager position to record the position confound", + "the probes are seeded: showcase_share_new_inquiries_with_field_ops matches status='new', and the seed ships one new, one contacted and one closed showcase_inquiry (OWD private). Use the new one as the MATCHING probe and a non-new one as the NON-MATCHING probe", "as the audience member: GET the object list and GET each probe by id", "as a member OUTSIDE the audience: same reads", "read sys_record_share for the matching record; run /security/explain for the audience member + matching record", "manual-share the non-matching inquiry to a specific user via the record-shares route (POST /api/v1/data/showcase_inquiry//shares); that user re-reads, and a third member re-reads too. The recipient needs object-level read on the object: a share on showcase_contact to a baseline member lands 201 but the member's read answers 403 at the object gate, because the member baseline grants no showcase_contact read — so a manual share is only observable on an object the recipient can already read at object level", - "§A13: as admin switch the DECLARED rule off at the activation door — POST /api/v1/security/_activation/sharing_rule/share_new_inquiries_with_field_ops {\"enabled\": false}; re-read the audience member's list and by-id read of the matching inquiry, the rule-sourced sys_record_share rows and the sys_metadata_activation row; restart the server on the same database and re-read; switch it back on ({\"enabled\": true}), re-read, restart once more and re-read. There is no seeded sys_sharing_rule row to edit any more: declared rules are read from the registry (ADR-0131 D2, packages/plugins/plugin-sharing/src/declared-sharing-rules.ts) and GET /api/v1/data/sys_sharing_rule answers total 0 on stock, so the activation switch is the administrator's durable artifact. The materialized share is not editable: PATCH /api/v1/data/sys_record_share/ answers 405 OBJECT_API_METHOD_NOT_ALLOWED (allowed get, list, aggregate, search, export)" + "§A13: as admin switch the DECLARED rule off at the activation door — POST /api/v1/security/_activation/sharing_rule/showcase_share_new_inquiries_with_field_ops {\"enabled\": false}; re-read the audience member's list and by-id read of the matching inquiry, the rule-sourced sys_record_share rows and the sys_metadata_activation row; restart the server on the same database and re-read; switch it back on ({\"enabled\": true}), re-read, restart once more and re-read. There is no seeded sys_sharing_rule row to edit any more: declared rules are read from the registry (ADR-0131 D2, packages/plugins/plugin-sharing/src/declared-sharing-rules.ts) and GET /api/v1/data/sys_sharing_rule answers total 0 on stock, so the activation switch is the administrator's durable artifact. The materialized share is not editable: PATCH /api/v1/data/sys_record_share/ answers 405 OBJECT_API_METHOD_NOT_ALLOWED (allowed get, list, aggregate, search, export)" ], "acceptance": [ { @@ -1275,7 +1275,7 @@ "evidence": "the two reads" }, { - "clause": "§A13 — an administrator's switch on a DECLARED sharing rule survives a restart and is never undone by the boot: switched off at the activation door, share_new_inquiries_with_field_ops withdraws its grant (the audience member is denied, its rule-sourced sys_record_share row is gone, sys_metadata_activation reads active false); a cold restart on the same database leaves it off — the boot neither re-arms it nor re-materializes the grant; switched back on it restores exactly one rule-sourced share, which a second restart keeps. The declared rule is code with no stored row (ADR-0131 D2), so the ledger row IS the administrator's edit; sys_record_share refuses edits (405)", + "clause": "§A13 — an administrator's switch on a DECLARED sharing rule survives a restart and is never undone by the boot: switched off at the activation door, showcase_share_new_inquiries_with_field_ops withdraws its grant (the audience member is denied, its rule-sourced sys_record_share row is gone, sys_metadata_activation reads active false); a cold restart on the same database leaves it off — the boot neither re-arms it nor re-materializes the grant; switched back on it restores exactly one rule-sourced share, which a second restart keeps. The declared rule is code with no stored row (ADR-0131 D2), so the ledger row IS the administrator's edit; sys_record_share refuses edits (405)", "oracle": "api", "verify": "per stage — baseline, off, restart, on, restart — the audience member's list + by-id read of the matching inquiry, the outsider's read, the sys_record_share rows with source 'rule' for the rule (none while off, exactly one while on, the same id after the second restart), and the sys_metadata_activation row (active false/true, updated_at unchanged across each restart). ⛔ GET /api/v1/meta/sharing_rule/NAME keeps answering active: true while the ledger holds the rule off — that read is the DECLARATION, not the effective state, so it is never this clause's oracle", "evidence": "the five-stage read table (member, outsider, share rows, ledger row)" @@ -1340,8 +1340,8 @@ "requires": [ "showcase_private_note (sharingModel: private — member_default grants create/read/edit, so a non-owner sees nothing without a share)", "three fresh sign-ups (A/B/C) whose sys_user ids are resolved via a system-context read", - "the seeded criteria sharing rule share_key_account_qualified_contacts_with_managers on showcase_contact (OWD private) + at least one qualified contact at company Northwind — seeded as Nora West (examples/app-showcase/src/security/sharing-rules.ts) — for the evaluate clause", - "a holder of the manager position, so the rule's recipient expansion is non-empty. Since #9237 the STOCK boot already satisfies this — the seeded dev admin holds manager (sys_user_position usp_showcase_admin_manager) and the boot backfill materialises the grant — so an evaluate with no extra assignment answers grantsCreated 0 out of IDEMPOTENCE, not emptiness. Read the gap off expandedUsers/matchedRecords. Assigning the position to one more persona does NOT make a created grant observable on evaluate either: the sys_user_position insert itself re-evaluates every position-recipient rule naming manager for the one user the row names, in the same write path (packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts#bindRecipientMembershipRecompute, bound by sharing-plugin.ts), so that persona's grant is materialised before the insert returns and the evaluate that follows answers grantsCreated 0 again (QA run #27392). The observable is the rule-sourced sys_record_share row for that persona (recipient_id = its sys_user id, source 'rule', source_id = the rule's id), never grantsCreated > 0" + "the seeded criteria sharing rule showcase_share_key_account_qualified_contacts_with_managers on showcase_contact (OWD private) + at least one qualified contact at company Northwind — seeded as Nora West (examples/app-showcase/src/security/sharing-rules.ts) — for the evaluate clause", + "a holder of the showcase_manager position, so the rule's recipient expansion is non-empty. Since #9237 the STOCK boot already satisfies this — the seeded dev admin holds manager (sys_user_position usp_showcase_admin_manager) and the boot backfill materialises the grant — so an evaluate with no extra assignment answers grantsCreated 0 out of IDEMPOTENCE, not emptiness. Read the gap off expandedUsers/matchedRecords. Assigning the position to one more persona does NOT make a created grant observable on evaluate either: the sys_user_position insert itself re-evaluates every position-recipient rule naming manager for the one user the row names, in the same write path (packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts#bindRecipientMembershipRecompute, bound by sharing-plugin.ts), so that persona's grant is materialised before the insert returns and the evaluate that follows answers grantsCreated 0 again (QA run #27392). The observable is the rule-sourced sys_record_share row for that persona (recipient_id = its sys_user id, source 'rule', source_id = the rule's id), never grantsCreated > 0" ] }, "steps": [ @@ -1355,7 +1355,7 @@ "as B (a non-manager on A's note): POST /api/v1/data/showcase_private_note//shares granting themselves — must be refused (creating a share is not a reader's power, ADR-0111 D5)", "revoke-scope probe — run it BEFORE the real revoke, while the share still EXISTS: attempt DELETE of the shareId through the SECOND note's path (/data/showcase_private_note//shares/) — must be refused (the URL's object/id is the revoke scope, ADR-0111 D4) AND the share must survive the attempt. Ordered after the revoke it proves nothing: the shareId no longer exists by then, so the same refusal comes back for a share that is simply gone", "as A: DELETE /api/v1/data/showcase_private_note//shares/ → 204; then as B: GET the note by id again — refused once more", - "as admin: assign the manager position to one persona (Setup -> Access Control) — the assignment itself materialises that persona's rule grant (fixtures.requires[3]) — then POST /api/v1/sharing/rules/share_key_account_qualified_contacts_with_managers/evaluate — capture the reconcile result, which answers grantsCreated 0 (idempotent) beside matchedRecords >= 1; read sys_record_share for the matched contact: the rule-sourced row whose recipient_id is the newly assigned persona is the created grant, read off the row and never off grantsCreated" + "as admin: assign the showcase_manager position to one persona (Setup -> Access Control) — the assignment itself materialises that persona's rule grant (fixtures.requires[3]) — then POST /api/v1/sharing/rules/showcase_share_key_account_qualified_contacts_with_managers/evaluate — capture the reconcile result, which answers grantsCreated 0 (idempotent) beside matchedRecords >= 1; read sys_record_share for the matched contact: the rule-sourced row whose recipient_id is the newly assigned persona is the created grant, read off the row and never off grantsCreated" ], "acceptance": [ { @@ -1395,9 +1395,9 @@ "evidence": "the mis-scoped DELETE trace + survival read" }, { - "clause": "rule evaluate reconciles the audience: POST /sharing/rules/share_key_account_qualified_contacts_with_managers/evaluate returns {ruleId, matchedRecords>=1, grantsCreated/grantsUpdated} and a sys_record_share row exists for the matched contact with source 'rule' and source_id = the rule's id. For this DECLARED rule that id IS its name — a declared rule is projected from the registry, not stored, and its id is its name (ADR-0131 D2) — while an organization-authored rule's id is its sys_sharing_rule row id (srule_…)", + "clause": "rule evaluate reconciles the audience: POST /sharing/rules/showcase_share_key_account_qualified_contacts_with_managers/evaluate returns {ruleId, matchedRecords>=1, grantsCreated/grantsUpdated} and a sys_record_share row exists for the matched contact with source 'rule' and source_id = the rule's id. For this DECLARED rule that id IS its name — a declared rule is projected from the registry, not stored, and its id is its name (ADR-0131 D2) — while an organization-authored rule's id is its sys_sharing_rule row id (srule_…)", "oracle": "api", - "verify": "the SharingRuleEvaluationResult body (packages/plugins/plugin-sharing/src/sharing-rule-service.ts evaluateRule) + the materialized rule-sourced share row: assert share.source_id === the body's ruleId, which for this rule is 'share_key_account_qualified_contacts_with_managers' (packages/plugins/plugin-sharing/src/sharing-rule-service.ts#projectDeclaredRule projects a declared rule with id = name, and that id is what its grants carry as sys_record_share.source_id). The rule id is the stable key the reconcile path relies on — revokeRuleGrants(ruleId) purges where {source:'rule', source_id: ruleId}. ⛔ Do not look for a sys_sharing_rule row for this rule: that table holds only organization-authored rules and answers total 0 on stock", + "verify": "the SharingRuleEvaluationResult body (packages/plugins/plugin-sharing/src/sharing-rule-service.ts evaluateRule) + the materialized rule-sourced share row: assert share.source_id === the body's ruleId, which for this rule is 'showcase_share_key_account_qualified_contacts_with_managers' (packages/plugins/plugin-sharing/src/sharing-rule-service.ts#projectDeclaredRule projects a declared rule with id = name, and that id is what its grants carry as sys_record_share.source_id). The rule id is the stable key the reconcile path relies on — revokeRuleGrants(ruleId) purges where {source:'rule', source_id: ruleId}. ⛔ Do not look for a sys_sharing_rule row for this rule: that table holds only organization-authored rules and answers total 0 on stock", "evidence": "evaluate response + the sys_record_share read (the row's source_id beside the body's ruleId)" } ], @@ -1413,7 +1413,7 @@ "packages/rest/src/sharing-routes.ts#registerSharingEndpoints (the record-shares family: GET and POST /data/:object/:id/shares and DELETE .../:shareId, the body of RestServer#registerSharingEndpoints moved into this module whole) + packages/rest/src/sharing-routes.ts#registerSharingRuleEndpoints (the evaluate leg). The registerRecordShareEndpoints an older revision named still does not exist (#13788); the family's registrar is registerSharingEndpoints", "packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts#recipient_type (recipient_type/recipient_id/access_level/source fields)", "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#evaluateRule (evaluateRule → SharingRuleEvaluationResult)", - "examples/app-showcase/src/security/sharing-rules.ts#share_key_account_qualified_contacts_with_managers (share_key_account_qualified_contacts_with_managers seeded criteria rule)", + "examples/app-showcase/src/security/sharing-rules.ts#showcase_share_key_account_qualified_contacts_with_managers (showcase_share_key_account_qualified_contacts_with_managers seeded criteria rule)", "ADR-0111 D1/D4/D5" ], "history": [ @@ -1922,7 +1922,7 @@ "app": "showcase", "requires": [ "an object declaring publicSharing { enabled:true, allowedAudiences, allowedPermissions, redactFields } with at least one visible record — REQUIRED to mint any link", - "showcase_client_brief is that object since #9308 fixture 2: publicSharing { enabled:true, allowedAudiences ['link_only','signed_in','email'], allowedPermissions ['view'], maxExpiryDays 30, redactFields ['internal_notes','deal_value'], eligibility \"record.status == 'published'\" }. The minter persona is a member holding showcase_client_liaison (bound to the client_liaison position); the seed carries a PUBLISHED brief ('Northwind — Website Relaunch brief', mint-eligible) and a DRAFT one ('Fabrikam — Compliance Audit brief', which the eligibility predicate refuses 422 RECORD_NOT_ELIGIBLE)" + "showcase_client_brief is that object since #9308 fixture 2: publicSharing { enabled:true, allowedAudiences ['link_only','signed_in','email'], allowedPermissions ['view'], maxExpiryDays 30, redactFields ['internal_notes','deal_value'], eligibility \"record.status == 'published'\" }. The minter persona is a member holding showcase_client_liaison (bound to the showcase_client_liaison position); the seed carries a PUBLISHED brief ('Northwind — Website Relaunch brief', mint-eligible) and a DRAFT one ('Fabrikam — Compliance Audit brief', which the eligibility predicate refuses 422 RECORD_NOT_ELIGIBLE)" ], "knownGaps": [ "[CLOSED by #9308 fixture 2 — the gap text stays because it carries the reason] NO stock showcase object set publicSharing.enabled (verified across examples/app-showcase/src), so createLink answered 422 SHARING_NOT_ENABLED for every showcase object and only the SHARING_NOT_ENABLED clause was runnable; the resolve/redact/password/revoke clauses were BLOCKED. showcase_client_brief now opts in (see requires). NOTE the negative control did NOT move: every OTHER showcase object still declines the opt-in, so clause 1's 422 is still runnable — probe it against showcase_private_note, not against the new object", @@ -2221,7 +2221,7 @@ "an audience position holding object-level allowRead on showcase_contact at the DEFAULT (own) record scope — this needs a SCRATCH permission set bound to a scratch position, because stock's only showcase_contact grant is showcase_manager { allowRead: true, readScope: 'org' } (permission-sets.ts), whose org-depth read already sees every contact and would mask the widening; and no other stock set grants showcase_contact at all, so any other stock position is refused by the OBJECT gate before record scope is ever consulted" ], "knownGaps": [ - "authoring a rule in Setup issues a direct sys_sharing_rule insert (packages/spec/src/security/sharing.zod.ts). Stock seeds TWO rules (examples/app-showcase/src/security/sharing-rules.ts allSharingRules; the showcase_project and showcase_task rules were retired because a public_read_write OWD leaves nothing to widen): share_new_inquiries_with_field_ops on showcase_inquiry, and share_key_account_qualified_contacts_with_managers ON showcase_contact — condition record.stage == 'qualified' && record.company == 'Northwind', shared with the manager position. So a seeded rule DOES touch this item's object. It does not confound the item, for two reasons the run must keep true: neither scored persona holds the manager position (the audience holds the scratch position, the outsider holds neither; the seeded dev admin does hold manager and is never a scored reader here), and acceptance[2] attributes a share by source_id = the NEW rule's row id, so a seeded-rule share on a Northwind-qualified probe is a different row. That item tests the SEEDED rules' enforcement; this one authors and retracts a rule in-run", + "authoring a rule in Setup issues a direct sys_sharing_rule insert (packages/spec/src/security/sharing.zod.ts). Stock seeds TWO rules (examples/app-showcase/src/security/sharing-rules.ts allSharingRules; the showcase_project and showcase_task rules were retired because a public_read_write OWD leaves nothing to widen): showcase_share_new_inquiries_with_field_ops on showcase_inquiry, and showcase_share_key_account_qualified_contacts_with_managers ON showcase_contact — condition record.stage == 'qualified' && record.company == 'Northwind', shared with the showcase_manager position. So a seeded rule DOES touch this item's object. It does not confound the item, for two reasons the run must keep true: neither scored persona holds the showcase_manager position (the audience holds the scratch position, the outsider holds neither; the seeded dev admin does hold manager and is never a scored reader here), and acceptance[2] attributes a share by source_id = the NEW rule's row id, so a seeded-rule share on a Northwind-qualified probe is a different row. That item tests the SEEDED rules' enforcement; this one authors and retracts a rule in-run", "the audience persona must be provisioned in-run (scratch permission set + scratch position, see fixtures.requires); a stock position cannot serve as the audience for a showcase_contact rule. Until that provisioning is scripted, treat a run that skips it as blocked(fixture) — never as a pass off the admin's own reads" ] }, @@ -2278,7 +2278,7 @@ ], "negative": [ "a rule that shares the NON-matching (stage='new') contact, or grants that survive the rule's deletion (orphaned sys_record_share), is a FAIL", - "do not overlap access-security.sharing-rules-widen: that item verifies the SEEDED rules' enforcement — this item authors a rule in-run on showcase_contact for a scratch position no seeded rule names (the seeded showcase_contact rule shares only with the manager position, see knownGaps), and verifies authoring + retraction", + "do not overlap access-security.sharing-rules-widen: that item verifies the SEEDED rules' enforcement — this item authors a rule in-run on showcase_contact for a scratch position no seeded rule names (the seeded showcase_contact rule shares only with the showcase_manager position, see knownGaps), and verifies authoring + retraction", "⛔ a run that authors the rule on a public_read_write object (showcase_project, showcase_task, …) proves nothing: the baseline is already org-wide, so the audience persona would read the 'matching' row with or without the rule and the item passes vacuously — the object under test must be OWD private" ], "traps": [ diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 11096298060..80a2219c32e 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -2736,14 +2736,14 @@ "surface": "api", "personas": [ "admin, authenticated — the live-boot half", - "a restricted member who cannot read every field of showcase_project — a member bound to the client_liaison position (showcase_client_liaison set: budget, budget_remaining and spent unreadable) — the masking clause is only observable as a non-admin", + "a restricted member who cannot read every field of showcase_project — a member bound to the showcase_client_liaison position (showcase_client_liaison set: budget, budget_remaining and spent unreadable) — the masking clause is only observable as a non-admin", "no persona for the variant half: a non-default `metadata` block is authorable only by a programmatic embedder (knownGaps)" ], "fixtures": { "app": "showcase", "requires": [ "a stock showcase boot for the default-config half (prefix /meta, cache on, masking ON by default)", - "a persona whose field-level security hides at least one field of a served object — on stock that is the client_liaison position's showcase_client_liaison set, which withholds showcase_project.budget, .spent and .budget_remaining (readable:false); no stock persona carries FLS on any showcase_account field, so a showcase_account diff is empty for everyone. Without such a persona the masking clause scores blocked(fixture), never green", + "a persona whose field-level security hides at least one field of a served object — on stock that is the showcase_client_liaison position's showcase_client_liaison set, which withholds showcase_project.budget, .spent and .budget_remaining (readable:false); no stock persona carries FLS on any showcase_account field, so a showcase_account diff is empty for everyone. Without such a persona the masking clause scores blocked(fixture), never green", "a scratch vitest harness in packages/rest for the variant half (construct, registerRoutes(), getRoutes())" ], "knownGaps": [ @@ -2754,7 +2754,7 @@ "steps": [ "boot the showcase on its own port and DB; as admin fire the default-prefix metadata mounts and capture status: GET /api/v1/meta, /meta/types, /meta/diagnostics, /meta/_drafts, GET /meta/object, GET /meta/object/showcase_task, /meta/object/showcase_task/references, /meta/object/showcase_task/layers, /meta/book//tree; fire the routing-404 control alongside", "GET /api/v1/discovery and read routes.metadata — it must equal the base plus the configured prefix", - "as the restricted persona (a member bound to the client_liaison position) GET /api/v1/meta/object/showcase_project and diff the served fields against the admin's copy of the same document", + "as the restricted persona (a member bound to the showcase_client_liaison position) GET /api/v1/meta/object/showcase_project and diff the served fields against the admin's copy of the same document", "harness: construct with { metadata: { prefix: '/metadata' } } and confirm every metadata mount re-bases and the discovery body advertises the new prefix", "harness: construct with { metadata: { endpoints: { types: false } } } — GET {prefix} and GET {prefix}/types both disappear (one handler, two paths)", "harness: construct with { metadata: { endpoints: { items: false } } } and enumerate what leaves: GET {prefix}/:type and NOTHING else — exactly the one mount its describe() names (#15542 moved the whole-store family off this switch; a run that still sees /diagnostics, /_drafts or POST /_migrate-stored leave with it is the old radius returning)", @@ -3224,7 +3224,7 @@ "GET /api/v1/meta/email_template/NAME?member=zh-CN; GET /api/v1/meta/object/showcase_task?member=x", "GET /api/v1/meta/object/showcase_task?memeber=x (typo)", "GET /api/v1/meta/view?package=all vs GET /api/v1/meta/view", - "as a member whose FLS hides a field F: the stock showcase declares field-level security on showcase_project only (showcase_client_liaison hides budget, budget_remaining and spent), and no demo persona holds the client_liaison position, so mint the member (POST /api/v1/auth/admin/create-user, then the password change) and bind it with a sys_user_position row for client_liaison; GET /api/v1/meta/object/showcase_project and read sortability for budget; GET /api/v1/data/showcase_project?sort=budget" + "as a member whose FLS hides a field F: the stock showcase declares field-level security on showcase_project only (showcase_client_liaison hides budget, budget_remaining and spent), and no demo persona holds the showcase_client_liaison position, so mint the member (POST /api/v1/auth/admin/create-user, then the password change) and bind it with a sys_user_position row for client_liaison; GET /api/v1/meta/object/showcase_project and read sortability for budget; GET /api/v1/data/showcase_project?sort=budget" ], "acceptance": [ { @@ -3254,7 +3254,7 @@ { "clause": "for the FLS-restricted caller F is ABSENT from sortability.fields (never offered — it is not listed with sortable: false) and the data sort on F is 403 — the two agree", "oracle": "api", - "verify": "sortability.fields without F + the data answer, for a member bound to the client_liaison position. meta-read-doors-live.dogfood.test.ts asserts both halves over HTTP on a booted showcase: budget absent from sortability.fields (name listed sortable; the admin's definition offers budget, the CONTROL), and GET /api/v1/data/showcase_project?sort=budget → 403 PERMISSION_DENIED beside a served sort on name. packages/rest/src/meta-object-sortability-caller-fls.test.ts ('sortability never offers the unreadable field') holds the same rule at unit, and showcase-fls-read-mask-strip.dogfood.test.ts the data-sort 403 for a scratch permission set", + "verify": "sortability.fields without F + the data answer, for a member bound to the showcase_client_liaison position. meta-read-doors-live.dogfood.test.ts asserts both halves over HTTP on a booted showcase: budget absent from sortability.fields (name listed sortable; the admin's definition offers budget, the CONTROL), and GET /api/v1/data/showcase_project?sort=budget → 403 PERMISSION_DENIED beside a served sort on name. packages/rest/src/meta-object-sortability-caller-fls.test.ts ('sortability never offers the unreadable field') holds the same rule at unit, and showcase-fls-read-mask-strip.dogfood.test.ts the data-sort 403 for a scratch permission set", "evidence": "both excerpts" } ], diff --git a/docs/qa/platform-checklist/areas/approvals.json b/docs/qa/platform-checklist/areas/approvals.json index 7e368738a80..5195c6a6d66 100644 --- a/docs/qa/platform-checklist/areas/approvals.json +++ b/docs/qa/platform-checklist/areas/approvals.json @@ -54,7 +54,7 @@ "step": 6, "call": "PATCH /api/v1/data/showcase_project/PROJECT_ID", "body": { "budget": 200000 }, - "expect": "200; showcase_budget_approval opens its manager_review request inside B. Position 'manager' has no holder in B, so the slate falls to B's administrators (the timeline's comment row reads 'Routed to this organization's administrators because the approver slate named no one'), which is the dev admin. Then GET /api/v1/approvals/requests?status=pending and take the row whose record_id is PROJECT_ID: request R, organization_id = B, current_step manager_review", + "expect": "200; showcase_budget_approval opens its manager_review request inside B. Position 'showcase_manager' has no holder in B, so the slate falls to B's administrators (the timeline's comment row reads 'Routed to this organization's administrators because the approver slate named no one'), which is the dev admin. Then GET /api/v1/approvals/requests?status=pending and take the row whose record_id is PROJECT_ID: request R, organization_id = B, current_step manager_review", "source": "examples/app-showcase/src/automation/flows/index.ts#BudgetApprovalFlow (manager_review routes to position manager; the empty slate takes the default admin_rescue route)" }, { @@ -199,11 +199,11 @@ "app": "showcase", "requires": [ "a quorum (minApprovals: 2) request whose three position approvers resolve to three distinct users", - "RUNTIME FIXTURE (admin API only, stock boot, nothing seeded or committed; proved by run #21845) — apply it before the quorum steps, in this order: (1) bind a second committee position holder: as the admin, POST /api/v1/data/sys_user_position {user_id: , position: 'finance', organization_id: } — admin + Ada is a real 2-of-2 slate; (2) for the full 2-of-3, invite + sign up a third user and bind them to position 'legal' the same way. On a boot an earlier item already used (a fresh boot needs no unbinding), first list sys_user_position for positions finance and legal and unbind any other member a previous item bound there (e.g. approvals.account-app-entry's runtime persona, which holds finance) — a slate is every holder, so a leftover binding makes it four distinct people and the 2-of-3 never forms; prove each persona with GET /api/v1/auth/get-session before it acts; (3) DISABLE the competing per-group sign-off flow for the run: POST /api/v1/automation/showcase_expense_signoff/toggle {\"enabled\": false} — it has no amount ceiling and opens its request first on any >= 5000 report, so without this the quorum flow's open fails DUPLICATE_REQUEST; re-enable it with {\"enabled\": true} afterwards; (4) open a FRESH request after the bindings, because a slate is fixed when its request opens: create a draft showcase_expense_report, add one showcase_expense_line of 6000 (total_amount >= 5000, the committee threshold), then PATCH the report's status to 'submitted'. Use a second fresh request the same way for the veto case. The admin who creates the reports is then both the submitter and the third holder; that is harmless, because the tally counts distinct deciders and the 2-of-3 forms from the other two" + "RUNTIME FIXTURE (admin API only, stock boot, nothing seeded or committed; proved by run #21845) — apply it before the quorum steps, in this order: (1) bind a second committee position holder: as the admin, POST /api/v1/data/sys_user_position {user_id: , position: 'showcase_finance', organization_id: } — admin + Ada is a real 2-of-2 slate; (2) for the full 2-of-3, invite + sign up a third user and bind them to position 'showcase_legal' the same way. On a boot an earlier item already used (a fresh boot needs no unbinding), first list sys_user_position for positions finance and legal and unbind any other member a previous item bound there (e.g. approvals.account-app-entry's runtime persona, which holds finance) — a slate is every holder, so a leftover binding makes it four distinct people and the 2-of-3 never forms; prove each persona with GET /api/v1/auth/get-session before it acts; (3) DISABLE the competing per-group sign-off flow for the run: POST /api/v1/automation/showcase_expense_signoff/toggle {\"enabled\": false} — it has no amount ceiling and opens its request first on any >= 5000 report, so without this the quorum flow's open fails DUPLICATE_REQUEST; re-enable it with {\"enabled\": true} afterwards; (4) open a FRESH request after the bindings, because a slate is fixed when its request opens: create a draft showcase_expense_report, add one showcase_expense_line of 6000 (total_amount >= 5000, the committee threshold), then PATCH the report's status to 'submitted'. Use a second fresh request the same way for the veto case. The admin who creates the reports is then both the submitter and the third holder; that is harmless, because the tally counts distinct deciders and the 2-of-3 forms from the other two" ], "knownGaps": [ "the FLOW half of the fixture is real and seeded — showcase_committee_quorum (2-of-3 quorum over positions manager/finance/legal, examples/app-showcase/src/automation/flows/index.ts) is launched on EXP-DEMO by seed-approval-demo.ts. What still collapses the slate is POSITION ASSIGNMENT, not a missing flow or a pending design call: the admin holds all three committee positions (ADMIN_APPROVAL_POSITIONS = manager/finance/legal/exec, seed-approval-demo.ts) and Ada Auditor deliberately holds ONLY auditor, so all three slots resolve to one person and the runtime clamps 2-of-3 to 1-of-1", - "CLOSED for runs by the runtime fixture in requires (#21845) — the seed gap itself remains and is why the fixture exists: the remaining unblock is a SEED LINE: assign Ada 'finance' (or 'legal') in seed-approval-demo.ts. She has been a real credential login on a stock boot since #9308, and the per-group demo is untouched by the grant — its finance GROUP routes position 'auditor' (flows/index.ts), not position 'finance'. That one line yields a two-distinct-holder slate (admin + Ada), turning minApprovals 2 into a real 2-of-2: the threshold tally, actor distinctness and the one-rejection veto all become demonstrable. Only the 'third approver's pending task is closed by finalization' clause needs more — a THIRD distinct holder (e.g. a legal-holding persona), an equally mechanical seed addition. Landing those seed lines (and an amount ceiling on the per-group flow) retires the runtime fixture; until then a run states that its verdict rests on the runtime fixture, and the seeded EXP-DEMO request stays the clamp contrast because its slate was fixed when it opened at seed time" + "CLOSED for runs by the runtime fixture in requires (#21845) — the seed gap itself remains and is why the fixture exists: the remaining unblock is a SEED LINE: assign Ada 'showcase_finance' (or 'showcase_legal') in seed-approval-demo.ts. She has been a real credential login on a stock boot since #9308, and the per-group demo is untouched by the grant — its finance GROUP routes position 'showcase_auditor' (flows/index.ts), not position 'showcase_finance'. That one line yields a two-distinct-holder slate (admin + Ada), turning minApprovals 2 into a real 2-of-2: the threshold tally, actor distinctness and the one-rejection veto all become demonstrable. Only the 'third approver's pending task is closed by finalization' clause needs more — a THIRD distinct holder (e.g. a legal-holding persona), an equally mechanical seed addition. Landing those seed lines (and an amount ceiling on the per-group flow) retires the runtime fixture; until then a run states that its verdict rests on the runtime fixture, and the seeded EXP-DEMO request stays the clamp contrast because its slate was fixed when it opened at seed time" ] }, "steps": [ @@ -316,7 +316,7 @@ "requires": [ "requests with a real (non-null) submitter — seeded since #3411 (invoice submitted by admin, others by a no-position persona)", "seed-approval-demo.ts: the invoice dual sign-off request is submitted BY the admin (their own request, so the Submitted by me tab — 我发起的 on zh-CN — is non-empty), while EXP-2001 / EXP-DEMO are submitted by Mei Phone (other-submitter requests the admin approves)", - "a non-override approver on an other-submitter PENDING request. EXP-2001, the per_group Expense Sign-off, routes the seeded Ada Auditor (demo) persona for its finance group (ExpenseSignoffFlow: position 'auditor', group 'finance'; seed-approval-demo.ts gives Ada the auditor position), beside the dev admin's manager group. Ada is a plain organization member, so viewer.can_override is false for her. She is this persona ready-made, and she signs in with the demo persona password (examples/app-showcase/src/security/demo-personas.ts). EXP-DEMO, the quorum committee over manager / finance / legal, routes only the dev admin, an override actor. A run that uses EXP-DEMO for the other-submitter case still makes a plain member its approver first, e.g. through the request's reassign action as the admin, before step 2" + "a non-override approver on an other-submitter PENDING request. EXP-2001, the per_group Expense Sign-off, routes the seeded Ada Auditor (demo) persona for its finance group (ExpenseSignoffFlow: position 'showcase_auditor', group 'finance'; seed-approval-demo.ts gives Ada the showcase_auditor position), beside the dev admin's manager group. Ada is a plain organization member, so viewer.can_override is false for her. She is this persona ready-made, and she signs in with the demo persona password (examples/app-showcase/src/security/demo-personas.ts). EXP-DEMO, the quorum committee over manager / finance / legal, routes only the dev admin, an override actor. A run that uses EXP-DEMO for the other-submitter case still makes a plain member its approver first, e.g. through the request's reassign action as the admin, before step 2" ] }, "steps": [ @@ -954,11 +954,11 @@ "revision": 5, "priority": "P2", "surface": "browser", - "personas": ["dev admin (holds the manager position, so showcase_budget_approval's manager_review routes to them — a pending approver standing on the record page)"], + "personas": ["dev admin (holds the showcase_manager position, so showcase_budget_approval's manager_review routes to them — a pending approver standing on the record page)"], "fixtures": { "app": "showcase", "requires": [ - "showcase_budget_approval raised on a showcase_project by PATCHing budget above 100000 (budget != previous.budget) — parks at manager_review (approvers position 'manager' = the admin), lockRecord:false; on a budget above 500000, approving manager_review routes through needs_exec to exec_review (approvers position 'exec', which the admin also holds: examples/app-showcase/src/security/seed-approval-demo.ts#ADMIN_APPROVAL_POSITIONS), lockRecord:true (the locking contrast, objectui#2902). The admin's PATCH triggers the run, so the admin is each request's submitter as well (the approval node passes its trigger user)", + "showcase_budget_approval raised on a showcase_project by PATCHing budget above 100000 (budget != previous.budget) — parks at manager_review (approvers position 'showcase_manager' = the admin), lockRecord:false; on a budget above 500000, approving manager_review routes through needs_exec to exec_review (approvers position 'showcase_exec', which the admin also holds: examples/app-showcase/src/security/seed-approval-demo.ts#ADMIN_APPROVAL_POSITIONS), lockRecord:true (the locking contrast, objectui#2902). The admin's PATCH triggers the run, so the admin is each request's submitter as well (the approval node passes its trigger user)", "the record page surface: objectui packages/app-shell RecordDetailView (DeclaredActionsBar over the record's pending sys_approval_request row, location record_section) + useRecordApprovals (the record's approvals read) + recordApprovalActions.ts; the actions themselves are the server's declarations in packages/plugins/plugin-approvals/src/sys-approval-request.object.ts, each gated by its visible predicate over the row's server-computed viewer block" ] }, @@ -970,7 +970,7 @@ "click Reject (declared-action-approval_reject): assert exactly ONE dialog opens — the param dialog titled by the declared label, Reject, with the declared confirm question as its description ('Reject this request? A rejection is final for every approver.'), carrying the optional Comment textarea and Attachments file input inline; there is NO confirm dialog in front of it and no second comment dialog after it (approval_reject declares its question as description and no confirmText, so the runtime chains nothing, #7278; objectui#3126)", "fill the comment and Confirm; capture POST /api/v1/approvals/requests/:id/reject — :id is the REQUEST's id, never the project's — with the comment in its body; re-read GET /api/v1/approvals/requests/:id (status=rejected) and GET /api/v1/approvals/requests/:id/actions (the reject row carries the comment). The bar re-reads the approvals after the decision (refreshAfter) and draws nothing once no request is pending", "approve path: PATCH another project's budget to e.g. 200000; on its record page click Approve → one dialog (titled Approve, described 'Approve this request? Your approval is recorded, and the request moves on once this step has the approvals it requires.', optional Comment and Attachments) → Confirm → POST /api/v1/approvals/requests/:id/approve → re-read (status=approved; at or under 500000, needs_exec ends the run at approved)", - "lock contrast: PATCH a third project's budget above 500000 (e.g. 600000) and approve its manager_review from the record page as in step 7 — needs_exec routes the run to exec_review (position 'exec', the admin again; lockRecord:true), a NEW pending request whose row reads lock_record: true; once the page re-reads (the decision's refresh, or a reload), screenshot — the header's Edit button stays RENDERED but DISABLED (disabled, not hidden, so the user sees the affordance is off: RecordDetailView.tsx, framework#3794), the inline-edit pencils are gone (approvalLocked, from resolveApprovalLock over recordLockedByApproval of the pending request → InlineEditProvider canEdit false), and the approval band reads 'Locked for approval' (amber, a lock icon; detail.lockedByApproval)", + "lock contrast: PATCH a third project's budget above 500000 (e.g. 600000) and approve its manager_review from the record page as in step 7 — needs_exec routes the run to exec_review (position 'showcase_exec', the admin again; lockRecord:true), a NEW pending request whose row reads lock_record: true; once the page re-reads (the decision's refresh, or a reload), screenshot — the header's Edit button stays RENDERED but DISABLED (disabled, not hidden, so the user sees the affordance is off: RecordDetailView.tsx, framework#3794), the inline-edit pencils are gone (approvalLocked, from resolveApprovalLock over recordLockedByApproval of the pending request → InlineEditProvider canEdit false), and the approval band reads 'Locked for approval' (amber, a lock icon; detail.lockedByApproval)", "both-sides gate, closed side: open the step-8 project's record page (its exec_review is pending) as a member who can READ the project but is not on the slate, did not submit and holds no override — e.g. a contributor-bound member (the search:qa-contributor-bound-member recipe; RUNNER.md's self-registration entry says how to mint one). Capture the member's own GET /api/v1/approvals/requests?object=showcase_project&recordId=:id: it serves no row for this record (the approvals door serves a request to its participants and the override actor, and the record-reader tier is off: no showcase object declares enable.approvalsVisibleToReaders), so the page draws no declared decision action at all. Then forge the decision: as the member, POST /api/v1/approvals/requests/:id/approve with the id from the admin's read → 403, code FORBIDDEN ('is not a pending approver'), and the request re-reads still pending. ⛔ Not the dev admin: the admin holds the override (step 10), so the admin cannot prove the gate's closed side", "override contrast (the admin off the slate): on a pending manager_review (PATCH a fourth project to e.g. 200000), Reassign the step to another user through the bar's New approver picker — e.g. the seeded Ada Auditor (demo) persona (examples/app-showcase/src/security/demo-personas.ts) — so the request waits on that user and the admin holds no slot. Re-read the row (viewer.can_act false, viewer.can_override true) and screenshot the bar. The record page FOLDS the override levers for an override-only viewer (RecordDetailView.tsx passes foldOverrides; DeclaredActionsBar.tsx, objectstack#25877). The bar shows a 'Pending · Waiting on …' status line naming the approver the override would bypass, Reply as a plain button (the admin holds it as the submitter), and ONE amber menu trigger, a shield plus an overflow glyph, data-testid declared-actions-override-menu, labelled 'Admin override'. Open that menu and screenshot it. It lists 'Override Approve', 'Override Reject' and 'Override Reassign' (data-override-decision='true'), and Recall under its plain label: the admin holds Recall as the submitter, so it carries no override framing, yet it folds with the rest because its declared visible gate ORs in can_override. Send back and Request info are gone (their predicate reads can_act alone). That is the override arm of the same declared predicates, not the gate failing" ], @@ -1129,7 +1129,7 @@ "requires": [ "a bundled console: packages/console/dist is absent in a fresh checkout — build it with `pnpm console:build`, which builds ui/apps/console from this checkout and copies it to packages/console/dist (nothing is cloned or pinned: packages/console/build-dist.sh), and keep the ready line it prints. `pnpm build` builds the console but never refreshes that copy, so a dist older than the tree is the stale-console-bundle trap (RUNNER.md) — rebuild with `pnpm console:build` before trusting anything in the browser", "a non-admin sign-in account holding only business positions (showcase: contributor + finance) — the seeded personas (Ada Auditor, Mei Phone) ARE loginable since #9308 (showcase123, examples/app-showcase/src/security/demo-personas.ts) but hold the wrong positions (Mei none, Ada auditor only), so provision one at runtime and invite each one first — the stock showcase declares no auth.audience, so the invite_only default answers an uninvited POST /api/v1/auth/sign-up/email 403 SELF_REGISTRATION_CLOSED; an admin POST /api/v1/auth/organization/invite-member for the address admits the sign-up (or admin POST /api/v1/auth/admin/create-user, which answers mustChangePassword:true)", - "at least one pending request routed to that user (showcase: PATCH a draft invoice to status=sent + issued_on to launch showcase_invoice_signoff, whose finance/legal positions the persona holds). ⚠️ Launch a NEW one after the persona holds its positions: the sign-off requests seed-approval-demo.ts launches at boot were routed before the runtime persona held finance, so they never list it as a pending approver. Draft it against an account that has not churned: issuing against a churned account is refused 400 'This account has churned — reactivate it before invoicing.' (examples/app-showcase/src/data/objects/invoice.object.ts), and the first account a plain list returns may be churned. Make that invoice one the persona OWNS — acceptance[5] needs a row whose record the persona can read. ⚠️ Ownership here is the TEXT field `owner`, not owner_id: the contributor RLS invoice_own_rows reads `owner == current_user.email` (examples/app-showcase/src/security/permission-sets.ts), so the persona's create must carry `owner` = its own email: a contributor create whose `owner` is absent or names anyone else is refused 403 PERMISSION_DENIED with nothing written, by the create-time ownership check invoice_owner_on_create (the insert twin of invoice_owner_immutable, same file)" + "at least one pending request routed to that user (showcase: PATCH a draft invoice to status=sent + issued_on to launch showcase_invoice_signoff, whose showcase_finance/showcase_legal positions the persona holds). ⚠️ Launch a NEW one after the persona holds its positions: the sign-off requests seed-approval-demo.ts launches at boot were routed before the runtime persona held finance, so they never list it as a pending approver. Draft it against an account that has not churned: issuing against a churned account is refused 400 'This account has churned — reactivate it before invoicing.' (examples/app-showcase/src/data/objects/invoice.object.ts), and the first account a plain list returns may be churned. Make that invoice one the persona OWNS — acceptance[5] needs a row whose record the persona can read. ⚠️ Ownership here is the TEXT field `owner`, not owner_id: the contributor RLS invoice_own_rows reads `owner == current_user.email` (examples/app-showcase/src/security/permission-sets.ts), so the persona's create must carry `owner` = its own email: a contributor create whose `owner` is absent or names anyone else is refused 403 PERMISSION_DENIED with nothing written, by the create-time ownership check invoice_owner_on_create (the insert twin of invoice_owner_immutable, same file)" ] }, "steps": [ diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index 7f0bdec4b6e..8cf06aee445 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -2653,7 +2653,7 @@ "requires": [ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)", "a SCRATCH copy of examples/app-showcase (or a scaffolded blank app) for every planted-metadata step — never edit the shared fixtures; a file DB under /tmp/RUN/ for every boot", - "scratch flows: a record-triggered flow writing current_user.id, on an object the MEMBER may update — the stock member's own set (showcase_member_default) grants showcase_task read + create, NOT edit, so a member's PATCH of showcase_task answers 403 PERMISSION_DENIED: either bind the member to the contributor position (showcase_contributor edits the tasks assigned to them; the search:qa-contributor-bound-member recipe), or trigger the flow on the member's own showcase_private_note (QA run #24558); an autolaunched runAs:\"system\" flow; a decision node whose two edges are both true" + "scratch flows: a record-triggered flow writing current_user.id, on an object the MEMBER may update — the stock member's own set (showcase_member_default) grants showcase_task read + create, NOT edit, so a member's PATCH of showcase_task answers 403 PERMISSION_DENIED: either bind the member to the showcase_contributor position (showcase_contributor edits the tasks assigned to them; the search:qa-contributor-bound-member recipe), or trigger the flow on the member's own showcase_private_note (QA run #24558); an autolaunched runAs:\"system\" flow; a decision node whose two edges are both true" ] }, "steps": [ @@ -2875,7 +2875,7 @@ "surface": "mixed", "personas": [ "admin (holds manage_metadata; single posture)", - "dev admin as the approver (holds the manager position, so showcase_budget_approval's manager_review routes to them)" + "dev admin as the approver (holds the showcase_manager position, so showcase_budget_approval's manager_review routes to them)" ], "fixtures": { "app": "showcase", diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index 9c2c589978e..0bdfd8d54b6 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -868,7 +868,7 @@ "sign in as the org owner/admin and open Setup → People & Org → Organization (nav_organization: type object, objectName sys_organization, recordId {current_org_id}, cloud ADR-0081 D3); screenshot the org record page and confirm {current_org_id} resolved to the session's active org (not the list fallback)", "confirm the record page exposes the Members / Invitations / Teams tabs with the better-auth row actions (GET list-members, list-invitations, list-teams feed them)", "change a member's role: POST /api/v1/auth/organization/update-member-role (client organizations.updateMemberRole) to one of the ADR-0108 closed 4-name vocabulary {owner, admin, delegated_admin, member}; read the membership back and confirm the new role", - "prove the vocabulary is CLOSED, not merely conventional: attempt the same call with role 'guest' (and with any stack-declared position/permission-set name, e.g. showcase's 'contributor') — better-auth's role check refuses it (400 ROLE_NOT_FOUND) before any insert, and no membership/invitation row is left behind", + "prove the vocabulary is CLOSED, not merely conventional: attempt the same call with role 'guest' (and with any stack-declared position/permission-set name, e.g. showcase's 'showcase_contributor') — better-auth's role check refuses it (400 ROLE_NOT_FOUND) before any insert, and no membership/invitation row is left behind", "prove the role change bites: an operation the new role gates flips outcome for that member (e.g. promote to admin → an org-admin-only action now permitted; demote → refused)", "rename the organization: POST /api/v1/auth/organization/update (organizations.update) with a new name; re-read GET get-full-organization and confirm the rename persisted and the organization record page's title follows. The Setup nav entry does NOT follow: nav_organization's label is the static string 'Organization' (packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_organization)", "remove a member: POST /api/v1/auth/organization/remove-member (organizations.removeMember); confirm the member's org-scoped access SHRINKS through a request that is answered BECAUSE of membership of that org — e.g. GET /api/v1/auth/organization/get-full-organization?organizationId=ORG, or POST /api/v1/auth/organization/set-active for it — succeeding before and refused 403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION after. Read the after-side on a FRESH sign-in: remove-member also revokes the member's live sessions (revoke_reason organization_membership_ended), so the old session answers 401 to every request and discriminates nothing. ⛔ Not a data read: on the stock `single` posture the `everyone` baseline grants survive remove-member, so an ex-member still reads the same showcase rows and the check could not discriminate", @@ -964,10 +964,10 @@ "requires": [ "the organization capability mounted (for the team half — sys_team / sys_team_member via the better-auth org endpoints)", "the sys_business_unit tree available (managedBy 'platform' — writable over the data API, unlike the better-auth identity tables) with at least a root company node to parent a child under", - "for the scope-geometry clause: the showcase DOES ship a BU-consuming geometry — the `share_new_inquiries_with_field_ops` sharing rule expands the `bu_field_ops` SUBTREE (Field Operations + West/East Coast) onto showcase_inquiry, and the `showcase_field_ops_delegate` adminScope is bounded by the same tree (examples/app-showcase/src/security/sharing-rules.ts, seed/index.ts). The clause RUNS; it is not blocked" + "for the scope-geometry clause: the showcase DOES ship a BU-consuming geometry — the `showcase_share_new_inquiries_with_field_ops` sharing rule expands the `bu_field_ops` SUBTREE (Field Operations + West/East Coast) onto showcase_inquiry, and the `showcase_field_ops_delegate` adminScope is bounded by the same tree (examples/app-showcase/src/security/sharing-rules.ts, seed/index.ts). The clause RUNS; it is not blocked" ], "knownGaps": [ - "RETIRED (run #7663): the old gap read 'whether a stock persona's read is scoped BY the business-unit tree depends on the seeded config; if none consumes it the tree-widening clause is blocked(fixture)'. A BU-consuming geometry does ship (`share_new_inquiries_with_field_ops` → the bu_field_ops subtree), so the clause runs on stock showcase. Kept as a retired line rather than deleted so a future sweep does not re-block the clause on the retired reasoning", + "RETIRED (run #7663): the old gap read 'whether a stock persona's read is scoped BY the business-unit tree depends on the seeded config; if none consumes it the tree-widening clause is blocked(fixture)'. A BU-consuming geometry does ship (`showcase_share_new_inquiries_with_field_ops` → the bu_field_ops subtree), so the clause runs on stock showcase. Kept as a retired line rather than deleted so a future sweep does not re-block the clause on the retired reasoning", "ZERO SEEDED PLACEMENTS — the `seed-data-thin` trap for this item, and the thing that actually costs a run: a fresh boot seeds the sys_business_unit TREE (explicit ids, so metadata can reference units statically) but ZERO `sys_business_unit_member` rows — 'users can't be seeded (they sign up), so user↔unit membership and position assignments stay runtime admin actions' (examples/app-showcase/src/data/seed/index.ts). The sharing rule therefore materializes NOTHING until the tester places someone: create the sys_business_unit_member row FIRST, then read the scoped persona's rows. An empty before/after diff with no placement made is a fixture artifact, not a geometry failure" ] }, @@ -976,7 +976,7 @@ "remove one via remove-team-member and confirm the join row is gone (the endpoint keys on the (teamId,userId) pair, not the row id)", "create a CHILD sys_business_unit under an existing root: POST /api/v1/data/sys_business_unit with kind (company|division|department|office|cost_center) and parent_business_unit_id = the root's id; confirm it appears in the Org Chart tree view under its parent", "place a user in the child BU: create a sys_business_unit_member row (business_unit_id, user_id, function_in_business_unit member|lead|deputy, is_primary); confirm the placement via a data-API read", - "BU scope geometry IS configured on stock showcase — `share_new_inquiries_with_field_ops` expands the bu_field_ops subtree onto showcase_inquiry: as the scope-limited persona, record the showcase_inquiry row set visible BEFORE any placement (expect it EMPTY of shared rows — a fresh boot seeds zero sys_business_unit_member rows, so the rule matches nobody yet), then place the persona into bu_field_ops (or a descendant) and re-read — the visible set should WIDEN along the subtree (cross-ref access-security.scope-depth-asymmetry, which owns the depth matrix)", + "BU scope geometry IS configured on stock showcase — `showcase_share_new_inquiries_with_field_ops` expands the bu_field_ops subtree onto showcase_inquiry: as the scope-limited persona, record the showcase_inquiry row set visible BEFORE any placement (expect it EMPTY of shared rows — a fresh boot seeds zero sys_business_unit_member rows, so the rule matches nobody yet), then place the persona into bu_field_ops (or a descendant) and re-read — the visible set should WIDEN along the subtree (cross-ref access-security.scope-depth-asymmetry, which owns the depth matrix)", "move the child BU to a different parent (re-parent parent_business_unit_id) and, if geometry consumes it, re-read the scoped persona's rows to confirm the read follows the new tree position", "negative: attempt the team mutations and the BU writes as a non-admin and capture the refusals" ], @@ -1002,7 +1002,7 @@ { "clause": "the shipped BU geometry is load-bearing: a placement changes a scoped persona's visible rows along the tree — widening (placed higher / into a parent that expands subordinates) or narrowing accordingly; re-parenting moves the read with it", "oracle": "api", - "verify": "the scoped persona's row set before vs after the placement/re-parent differs exactly by the subtree `share_new_inquiries_with_field_ops` expands (bu_field_ops + descendants). The BEFORE read is expected to show none of the shared rows — a fresh boot seeds the tree but zero sys_business_unit_member rows, so the rule materializes nothing until the placement exists; make the placement, do not record a block", + "verify": "the scoped persona's row set before vs after the placement/re-parent differs exactly by the subtree `showcase_share_new_inquiries_with_field_ops` expands (bu_field_ops + descendants). The BEFORE read is expected to show none of the shared rows — a fresh boot seeds the tree but zero sys_business_unit_member rows, so the rule materializes nothing until the placement exists; make the placement, do not record a block", "evidence": "the before/after scoped reads + the sys_business_unit_member row that made the difference" }, { @@ -1023,7 +1023,7 @@ "packages/platform-objects/src/identity/sys-business-unit.object.ts#parent_business_unit_id (canonical BU tree ADR-0057 D2; kind enum; parent_business_unit_id self-ref; org_chart tree view; managedBy 'platform' — writable over the data API)", "packages/platform-objects/src/identity/sys-business-unit-member.object.ts#function_in_business_unit (user↔BU placement: function_in_business_unit member/lead/deputy, is_primary)", "docs/qa/platform-checklist/areas/access-security.json#items (access-security.scope-depth-asymmetry — the depth matrix this cross-references for the tree-widening geometry — re-pointed #18104: access is not a key this JSON declares; a cross-area citation names the items block, and the item id stays in this parenthetical)", - "examples/app-showcase/src/security/sharing-rules.ts#share_new_inquiries_with_field_ops (`share_new_inquiries_with_field_ops` — the shipped BU-consuming geometry: expands the bu_field_ops subtree onto showcase_inquiry)", + "examples/app-showcase/src/security/sharing-rules.ts#showcase_share_new_inquiries_with_field_ops (`showcase_share_new_inquiries_with_field_ops` — the shipped BU-consuming geometry: expands the bu_field_ops subtree onto showcase_inquiry)", "examples/app-showcase/src/data/seed/index.ts#sys_business_unit (the sys_business_unit tree is seeded with explicit ids; user↔unit membership — sys_business_unit_member — and position assignments are NOT seeded, they stay runtime admin actions)" ], "history": [ diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 14569d9cb78..c7e3a56afea 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -1862,7 +1862,7 @@ "app": "showcase", "requires": [ "a boot on its own port and file DB (-d file:/tmp//data.db) WITH direct sqlite access to that file between boots: the object declares apiMethods ['get','list'], so REST can never stage the org-row and duplicate-row probes — same scratch-DB class of fixture platform-core.interrupted-migration-boot-report uses. On a stock boot no 'cloud' datasource is registered, so the table rides the global default driver and lives in the run's own file DB", - "the shipped switches as row producers: the flow toggle (POST /api/v1/automation/showcase_task_completed/toggle), the action activation door (POST /api/v1/actions/_activation/showcase_task/showcase_mark_done), and the security catalog activation door POST /api/v1/security/_activation/:type/:name for :type position, permission, sharing_rule and webhook — the door also switches queue and assignment_rule (ADR-0142 D1/D5), but the stock showcase declares neither (examples/app-showcase/src/coverage.ts records both as not yet demonstrated), so no stock switch writes those two (packages/runtime/src/domains/catalog-activation.ts — e.g. position legal, permission set showcase_auditor, the declared sharing rule share_new_inquiries_with_field_ops and the declared webhook showcase_task_changed (examples/app-showcase/src/automation/webhooks/index.ts, shipped isActive false); ⛔ never admin_full_access, which the last-admin guard refuses to switch off). Their own door contracts belong to automation.packaged-flow-disable-durable, api-backend.packaged-action-disabled-dispatch and access-security.security-catalog-activation-and-clone-doors — this item owns the ROWS they leave behind" + "the shipped switches as row producers: the flow toggle (POST /api/v1/automation/showcase_task_completed/toggle), the action activation door (POST /api/v1/actions/_activation/showcase_task/showcase_mark_done), and the security catalog activation door POST /api/v1/security/_activation/:type/:name for :type position, permission, sharing_rule and webhook — the door also switches queue and assignment_rule (ADR-0142 D1/D5), but the stock showcase declares neither (examples/app-showcase/src/coverage.ts records both as not yet demonstrated), so no stock switch writes those two (packages/runtime/src/domains/catalog-activation.ts — e.g. position legal, permission set showcase_auditor, the declared sharing rule showcase_share_new_inquiries_with_field_ops and the declared webhook showcase_task_changed (examples/app-showcase/src/automation/webhooks/index.ts, shipped isActive false); ⛔ never admin_full_access, which the last-admin guard refuses to switch off). Their own door contracts belong to automation.packaged-flow-disable-durable, api-backend.packaged-action-disabled-dispatch and access-security.security-catalog-activation-and-clone-doors — this item owns the ROWS they leave behind" ], "knownGaps": [ "metadata_type is an untyped Field.text (maxLength 100) with three writer sites and eight values in the tree — 'flow' (packages/services/service-automation/src/flow-activation-store.ts), 'action' (packages/objectql/src/action-activation.ts), and 'position' / 'permission' / 'sharing_rule' / 'webhook' / 'queue' / 'assignment_rule' (packages/runtime/src/domains/catalog-activation.ts, which constructs the shared store with the path's :type and closes that :type to exactly those six in CATALOG_ACTIVATION_SUBJECTS — sharing_rule since ADR-0131 D6, webhook since ADR-0131 D2/D6 put a stack-declared webhook under regime C, queue and assignment_rule since ADR-0142 D1/D5 gave a declared ownership queue and a declared assignment rule a switch). There is NO spec enum to pin: the variants below are enumerated BY HAND and deliberately carry no enumSource, so a further writer (ADR-0126 §8 pre-charts tool/skill) lands without failing any ratchet. Re-derive the writer list (grep the METADATA_TYPE literals and every `new ObjectStoreMetadataActivationStore(` call site) before scoring the scope wall, and bump this item's variants when a new writer ships — revision 3 is that bump for the catalog door, revision 5 the one for its sharing_rule :type, revision 8 the one for its webhook :type, revision 9 the one for its queue and assignment_rule :types", @@ -1870,7 +1870,7 @@ ] }, "steps": [ - "boot on its own port with that file DB; as admin flip EVERY shipped switch off — the flow toggle (showcase_task_completed), the action flip (showcase_mark_done), and the catalog door for one position, one permission set, one declared sharing rule and the declared webhook (POST /api/v1/security/_activation/position/legal, /permission/showcase_auditor, /sharing_rule/share_new_inquiries_with_field_ops and /webhook/showcase_task_changed, body {\"enabled\": false} — the webhook ships isActive false, so its switch-off writes a row and changes no delivery); capture the six 200s", + "boot on its own port with that file DB; as admin flip EVERY shipped switch off — the flow toggle (showcase_task_completed), the action flip (showcase_mark_done), and the catalog door for one position, one permission set, one declared sharing rule and the declared webhook (POST /api/v1/security/_activation/position/showcase_legal, /permission/showcase_auditor, /sharing_rule/showcase_share_new_inquiries_with_field_ops and /webhook/showcase_task_changed, body {\"enabled\": false} — the webhook ships isActive false, so its switch-off writes a row and changes no delivery); capture the six 200s", "read the ledger through the open read path: GET /api/v1/data/sys_metadata_activation — capture every row; each carries the four declared ledger columns (metadata_type, name, package_id, active) beside the driver's id and the injected audit family, and NO tenant column: organization_id is ABSENT from the row's key set, never present-and-NULL on it. ⛔ Assert the KEY SET, never a value read — a value assertion of the shape `row.organization_id ?? null` is `null` for a column that does not exist, so it passes while measuring nothing (the dogfood pin was inverted into a key-set assertion for exactly this reason)", "scope wall sweep: the distinct metadata_type set over ALL rows is exactly {'flow','action','position','permission','sharing_rule','webhook'} — the six values steps[0]'s stock switches write. The wall itself is eight values: those six plus 'queue' and 'assignment_rule', which the same catalog door writes for a declared queue or assignment rule (ADR-0142 D1/D5), and the stock showcase declares neither. Any other value (hook/capability/tool/skill/…) is a FAIL of the model, not a coverage discovery: hooks are code-only by ADR-0126 §3 amendment ruling 1 (no ledger row, no disable switch), and a capability is code-only on the regime with no switch (catalog-activation.ts CATALOG_ACTIVATION_SUBJECTS). Then probe the door's own wall: POST /api/v1/security/_activation/capability/showcase.export_data {\"enabled\": false} answers 400 naming the six types it switches (position, permission, sharing_rule, webhook, queue, assignment_rule), and POST /api/v1/security/_activation/position/everyone {\"enabled\": false} answers 400 (an audience anchor) — re-read the ledger: neither left a row. Position and permission rows are no longer foreign here: ADR-0131 D3 moved the on/off switch of a position and of a permission set off the catalog row into this ledger (regime C as ADR-0131 D6 amends ADR-0126 §3; readDisabledCatalogNames in packages/core/src/security/resolve-authz-context.ts reads these two types and never the row's active column), which supersedes the 2026-08-26 #12159 ruling that a packaged permission set rides its kind's own active field. sharing_rule rows are not foreign either: ADR-0131 D6 charts a package-declared sharing rule under regime C, and the same door writes its row (packages/runtime/src/domains/catalog-activation.ts, CATALOG_ACTIVATION_SUBJECTS) — an organization-authored sys_sharing_rule row keeps its own switch and writes none. webhook rows are not foreign either: ADR-0131 D2/D6 put a stack-declared webhook (defineStack({ webhooks })) under regime C, the same door writes its row (CATALOG_ACTIVATION_SUBJECTS, WEBHOOK_ACTIVATION_SUBJECT), and plugin-webhooks reads it back (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#readWebhookActivation) — an organization-authored sys_webhook row keeps its own active field and writes none", "write-refusal probes, all three verbs: POST /api/v1/data/sys_metadata_activation {metadata_type:'action',name:'qa_probe',package_id:'qa',active:false}, PATCH /api/v1/data/sys_metadata_activation/ {active:true}, DELETE /api/v1/data/sys_metadata_activation/ — each answers 405 naming the refused operation and the allowed set; re-read confirms every row unchanged", diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index fee8a9bec68..c965c139a08 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -1606,7 +1606,7 @@ "the permission-zoo sets examples/app-showcase/src/security/permission-sets.ts: showcase_manager grants showcase_contact read (line 124), showcase_contributor omits it (lines 36-45) — the both-sides read-gate probe binds a user to each" ], "knownGaps": [ - "STALE (QA run #24616): the child-read-gate clause needs two members bound to the manager and contributor positions, and a stock boot provides them — the search:qa-contributor-bound-member recipe (invite, sign-up, then an admin POST of sys_user_position; run it twice, once with position 'manager', once with 'contributor'). It is not blocked(fixture) on a single-user boot; the paging clauses still run on the seeded admin alone", + "STALE (QA run #24616): the child-read-gate clause needs two members bound to the showcase_manager and showcase_contributor positions, and a stock boot provides them — the search:qa-contributor-bound-member recipe (invite, sign-up, then an admin POST of sys_user_position; run it twice, once with position 'showcase_manager', once with 'showcase_contributor'). It is not blocked(fixture) on a single-user boot; the paging clauses still run on the seeded admin alone", "AUTOMATION LIVES IN THE objectui TREE, WHICH IS ui/ IN THIS CHECKOUT — `automated.ref` names objectui specs exclusively, by objectui-relative paths that resolve under ui/ (a unit suite at ui/packages/…, a Playwright spec at ui/e2e/…). They run from that tree: a unit suite with cwd ui/ (pnpm exec vitest run FILE), a live spec only against a booted stack serving a console built by pnpm console:build. Drive the item by hand in the browser and score it as a manual run, OR run the pin from ui/ and cite its output naming the framework commit it ran at. ⛔ Never score it as covered on the strength of the `automated` field alone — an unrun pin is a claim, not evidence. Full protocol: RUNNER.md, the objectui-pinned-automation standing fact, whose 'none of those specs exist here' predates the ui/ import." ] }, @@ -2444,7 +2444,7 @@ "drag one task bar to new dates; capture the PATCH and re-read the record over the API", "reload and confirm the bar re-renders from the persisted dates", "on an extended fixture (per knownGaps): configure dependencies + a locked row (the direct drag on a locked leaf needs only knownGaps[0]'s lockField overlay); run toolbar auto-schedule; capture the confirm dialog, cancel once (verify zero writes), run again and apply", - "trigger a veto path (write rejected server-side) and confirm the bar snaps back. Runnable on stock with no lockField (QA run #24616): bind a member to the `contributor` position (search:qa-contributor-bound-member's steps; task_own_rows shows it only its assigned tasks), open the gantt as that member, have the admin reassign one of its tasks away over the API, then drag that bar — the PATCH answers 404 and the bar snaps back with 'Save failed — the change was rolled back'" + "trigger a veto path (write rejected server-side) and confirm the bar snaps back. Runnable on stock with no lockField (QA run #24616): bind a member to the `showcase_contributor` position (search:qa-contributor-bound-member's steps; task_own_rows shows it only its assigned tasks), open the gantt as that member, have the admin reassign one of its tasks away over the API, then drag that bar — the PATCH answers 404 and the bar snaps back with 'Save failed — the change was rolled back'" ], "acceptance": [ { @@ -6156,7 +6156,7 @@ "surface": "mixed", "personas": [ "seeded admin (admin@objectos.ai / admin123) — the Default Organization owner and platform admin on a fresh boot", - "org member — created by the admin with POST /api/v1/auth/admin/create-user (stock showcase self-registration is closed: audience posture invite_only answers sign-up 403 SELF_REGISTRATION_CLOSED); joins the Default Organization as `member`, not platform admin, and lands mustChangePassword:true, i.e. a HELD session until it changes its password (POST /api/v1/auth/change-password). Neither stock demo persona is an updater on showcase_task: the updater is a member bound to the `contributor` position (allowEdit on showcase_task), whose task RLS (task_own_rows) shows only tasks assigned to it — so assign it some tasks first (admin PATCH assignee), QA runs #24558 / #24616" + "org member — created by the admin with POST /api/v1/auth/admin/create-user (stock showcase self-registration is closed: audience posture invite_only answers sign-up 403 SELF_REGISTRATION_CLOSED); joins the Default Organization as `member`, not platform admin, and lands mustChangePassword:true, i.e. a HELD session until it changes its password (POST /api/v1/auth/change-password). Neither stock demo persona is an updater on showcase_task: the updater is a member bound to the `showcase_contributor` position (allowEdit on showcase_task), whose task RLS (task_own_rows) shows only tasks assigned to it — so assign it some tasks first (admin PATCH assignee), QA runs #24558 / #24616" ], "fixtures": { "app": "showcase", diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index 362190d11c8..8dbef5362b7 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -7,11 +7,11 @@ "$comment": "AREA-LEVEL standing fixtures. An item opts in with fixtures.provisioning = { use: \"\", why: \"…\" }; the recipe is written ONCE here so several items do not carry drifting copies of the same call sequence. Recipes are runtime-provisioned (no repo file is touched, nothing to seed) and every call is grounded in framework source at the cited file:line — replay them literally, and if one 4xxs, re-read the citation before assuming the recipe rotted. Shape copied from qa-scratch-authz in areas/attachments-storage.json (#7716/#7670).", "qa-contributor-bound-member": { "title": "A CONTRIBUTOR-BOUND member — an identity that reads a strict SUBSET of showcase_invoice rows (invited sign-up + one sys_user_position row)", - "why": "Both-sides RLS needs a persona for whom a specific row is invisible while the object itself is readable. ⚠️ Neither seeded demo persona is that persona, and reaching for them is the trap this recipe closes — measured on 17.1.0 (#10257): Mei (phone.demo@example.com) holds NO position, so showcase_contributor never merges and showcase_invoice answers PERMISSION_DENIED — the OBJECT gate, no rows at all, not a subset; Ada (auditor.demo@example.com) holds `auditor` → showcase_auditor, which carries `showcase_invoice: { allowRead: true, viewAllRecords: true }` — VAMA bypasses record scope entirely, so she sees all 12 — also not a subset. The persona that IS a subset is a member bound to the `contributor` position: showcase_contributor grants read/create/edit on showcase_invoice while the `invoice_own_rows` RLS narrows selects to `owner == current_user.email`, and none of the seeded invoices are owned by a fresh sign-up. Nothing here needs a seed change or a repo file — the whole gap was that the recipe existed only as prose on one item, which is why a runner reached for the demo personas instead and recorded blocked(fixture).", + "why": "Both-sides RLS needs a persona for whom a specific row is invisible while the object itself is readable. ⚠️ Neither seeded demo persona is that persona, and reaching for them is the trap this recipe closes — measured on 17.1.0 (#10257): Mei (phone.demo@example.com) holds NO position, so showcase_contributor never merges and showcase_invoice answers PERMISSION_DENIED — the OBJECT gate, no rows at all, not a subset; Ada (auditor.demo@example.com) holds `showcase_auditor` → showcase_auditor, which carries `showcase_invoice: { allowRead: true, viewAllRecords: true }` — VAMA bypasses record scope entirely, so she sees all 12 — also not a subset. The persona that IS a subset is a member bound to the `showcase_contributor` position: showcase_contributor grants read/create/edit on showcase_invoice while the `invoice_own_rows` RLS narrows selects to `owner == current_user.email`, and none of the seeded invoices are owned by a fresh sign-up. Nothing here needs a seed change or a repo file — the whole gap was that the recipe existed only as prose on one item, which is why a runner reached for the demo personas instead and recorded blocked(fixture).", "provenance": "run #7629 (framework, 2026-08-11) drove search.rls-both-personas to 4/4 PASS on exactly this persona — 3 invoices visible to the member vs 15 to admin. Promoted from prose on that one item to a replayable area recipe by the 17.1.0 sweep (#10236 B1); the Mei/Ada measurements above are from #10257. Step 1 re-pointed invite-first by #21060 after QA run #21056 met the invite_only default (403 SELF_REGISTRATION_CLOSED on an uninvited sign-up). Step 5's body gained the `owner` it always needed after QA run #23166 met an own-row invisible to its creator (#23169). Steps 2 and 3 name the admin REST doors QA run #24574 measured (an admin GET of sys_user by email and an admin POST of sys_user_position, the position as plain text), and step 5 names the invoice number field `name`, after that run met 400 INVALID_FIELD on `number` and #24593 met 400 on an OData `$filter` (wave anchor #24579, PR #24671). Step 5 asks for a non-churned account after QA run #26456 (wave anchor #25807) met the first listed account churned and the invoice POST refused 400.", "app": "showcase", "requires": [ - "a stock dev boot — the `contributor` position declares `showcase_contributor` in its `permissionSets` (examples/app-showcase/src/security/positions.ts), which the resolver reads by name (ADR-0131 D3/D4). ⛔ Do NOT hand-create a sys_position_permission_set row: no resolver reads it, so it proves nothing about the merge", + "a stock dev boot — the `showcase_contributor` position declares `showcase_contributor` in its `permissionSets` (examples/app-showcase/src/security/positions.ts), which the resolver reads by name (ADR-0131 D3/D4). ⛔ Do NOT hand-create a sys_position_permission_set row: no resolver reads it, so it proves nothing about the merge", "an admin session for the position binding: the admin's POST /api/v1/data/sys_user_position is the insert (sys_user_position is admin-writable system data, packages/plugins/plugin-security/src/objects/sys-user-position.object.ts managedBy 'system-data'; QA run #24574 bound the member this way) — the same pattern access-security.json's permission-set items already replay", "a boot on its own port and file DB (dogfood §0); export OS_PORT= as well as passing -p (RUNNER.md's connector self-URL fact) so unrelated flow noise does not pollute the run", "an admin session to INVITE the step-1 address before step 1: POST /api/v1/auth/organization/invite-member {\"email\": \"\", \"role\": \"member\"}. The stock showcase declares no auth.audience, so its posture is the invite_only default, which answers an uninvited sign-up 403 SELF_REGISTRATION_CLOSED; a pending, unexpired sys_invitation row for the address is what admits it (an admin POST /api/v1/auth/admin/create-user is the other door, and answers mustChangePassword:true)" @@ -32,9 +32,9 @@ }, { "step": 3, - "call": "as admin, POST /api/v1/data/sys_user_position { user_id: , position: 'contributor', organization_id: } — `position` is the position's machine name as plain text; there is no sys_position object to point at (positions are metadata, plugin-security sys-user-position.object.ts)", - "expect": "one row. This is THE step that makes the persona a subset reader: `contributor` resolves to showcase_contributor (auto-bound at boot, see requires), which grants showcase_invoice allowRead/allowCreate/allowEdit, and the RLS rule `invoice_own_rows` (using: `owner == current_user.email`, positions: ['contributor']) narrows every select to rows the caller owns.", - "source": "examples/app-showcase/src/security/permission-sets.ts#ContributorPermissionSet — ContributorPermissionSet.objects.showcase_invoice and rowLevelSecurity['invoice_own_rows']; examples/app-showcase/src/security/positions.ts#contributor (the `contributor` position); bind-position-sets.ts for the position→set edge" + "call": "as admin, POST /api/v1/data/sys_user_position { user_id: , position: 'showcase_contributor', organization_id: } — `position` is the position's machine name as plain text; there is no sys_position object to point at (positions are metadata, plugin-security sys-user-position.object.ts)", + "expect": "one row. This is THE step that makes the persona a subset reader: `showcase_contributor` resolves to the permission set of the same name (auto-bound at boot, see requires), which grants showcase_invoice allowRead/allowCreate/allowEdit, and the RLS rule `invoice_own_rows` (using: `owner == current_user.email`, positions: ['showcase_contributor']) narrows every select to rows the caller owns.", + "source": "examples/app-showcase/src/security/permission-sets.ts#ContributorPermissionSet — ContributorPermissionSet.objects.showcase_invoice and rowLevelSecurity['invoice_own_rows']; examples/app-showcase/src/security/positions.ts#showcase_contributor (the `showcase_contributor` position); bind-position-sets.ts for the position→set edge" }, { "step": 4, @@ -224,12 +224,12 @@ "revision": 8, "priority": "P1", "surface": "api", - "personas": ["seeded admin (sees all invoices)", "signed-up member bound to the contributor position via the qa-contributor-bound-member recipe (invoice_own_rows RLS: owner == current_user.email) — ⛔ NOT one of the seeded demo personas: Mei holds no position (PERMISSION_DENIED, object gate) and Ada holds auditor with viewAllRecords (all 12). See the recipe."], + "personas": ["seeded admin (sees all invoices)", "signed-up member bound to the showcase_contributor position via the qa-contributor-bound-member recipe (invoice_own_rows RLS: owner == current_user.email) — ⛔ NOT one of the seeded demo personas: Mei holds no position (PERMISSION_DENIED, object gate) and Ada holds auditor with viewAllRecords (all 12). See the recipe."], "fixtures": { "app": "showcase", "requires": [ "seeded invoices INV-1001..INV-1012 (12 rows) with owner emails (ada@example.com ×5 / linus@example.com ×4 / grace@example.com ×3) none of which belong to the test member — INV-1003 is linus's, which is why it is the invisible row the guard uses", - "a signed-up member holding the contributor position — positions are seeded, users are not: sign the member up, then bind contributor (sys_user_position) before the run. The full replayable sequence, its premise guard and the two personas that do NOT work now live in the area recipe qa-contributor-bound-member (see fixtures.provisioning)" + "a signed-up member holding the showcase_contributor position — positions are seeded, users are not: sign the member up, then bind contributor (sys_user_position) before the run. The full replayable sequence, its premise guard and the two personas that do NOT work now live in the area recipe qa-contributor-bound-member (see fixtures.provisioning)" ], "provisioning": { "use": "qa-contributor-bound-member", @@ -279,7 +279,7 @@ ], "traps": ["wrong-persona", "seed-data-thin"], "source": [ - "examples/app-showcase/src/security/permission-sets.ts#invoice_own_rows (invoice_own_rows: owner == current_user.email, positions ['contributor'])", + "examples/app-showcase/src/security/permission-sets.ts#invoice_own_rows (invoice_own_rows: owner == current_user.email, positions ['showcase_contributor'])", "examples/app-showcase/src/data/seed/index.ts (INV-1001..1012 owner spread; 'ada sees INV-1001/1002's lines but never linus's INV-1003')", "packages/objectql/src/engine.ts#expandSearchOnAst (expandSearchOnAst ANDs into ast.where; count() rides the read middleware — the #2737 total-leak fix)", "packages/metadata-protocol/src/protocol-data-crud.ts#findData (the paged arm counts a searched list: engine.count is handed the query's search and searchFields, so total is the counted number of matches and hasMore agrees with it; the `countable` exception and its page-local estimate were deleted by #22790, PR #22857) + packages/rest/src/list-search-total-door.test.ts ('[#22790] a paged search reports the counted total', including 'a member counts only the matches it can read')" @@ -495,14 +495,14 @@ "surface": "mixed", "personas": [ "seeded admin (admin@objectos.ai — sees all)", - "signed-up member bound to the contributor position (invoice_own_rows RLS — the same restricted persona as search.rls-both-personas)" + "signed-up member bound to the showcase_contributor position (invoice_own_rows RLS — the same restricted persona as search.rls-both-personas)" ], "fixtures": { "app": "showcase", "requires": [ "the objectui console (app-shell) with CommandPaletteProvider — the ⌘K/Ctrl+K accelerator, the header Search button, and the ?palette=1/?cmdk=1 deep-link all open the SAME palette overlay (ADR-0054 C1/C3). The button, links, deep-links and programmatic callers share the idempotent openCommandPalette(); the keyboard accelerator TOGGLES by source design (objectui app-shell CommandPaletteProvider.tsx: \"the keydown handler toggles (close-on-repeat is a keyboard nicety)\"), so a repeated ⌘K closes the palette and that is not a FAIL", "the seeded CJK/pinyin rows 张伟 (zhangwei@huaning.example) and 华宁科技 (examples/app-showcase/src/data/seed/index.ts) so a CJK/pinyin query has a real hit; the search service (pinyin full-text plugin) installed so GET /api/v1/search resolves", - "seeded invoices INV-1001..INV-1012 (12 rows, owner spread ada/linus/grace) and a signed-up member bound to the contributor position (sys_user_position) so the RLS-parity side has an invisible row (INV-1003, owned by linus) to prove absence — provisioned by this area's qa-contributor-bound-member recipe, exactly as in search.rls-both-personas. ⚠ Its step-5 own-row invoice must carry `owner` set to the member's email: invoice_own_rows reads owner == current_user.email and `owner` has no default, so a member-created invoice without it is invisible to its creator (QA run #23166 A5)" + "seeded invoices INV-1001..INV-1012 (12 rows, owner spread ada/linus/grace) and a signed-up member bound to the showcase_contributor position (sys_user_position) so the RLS-parity side has an invisible row (INV-1003, owned by linus) to prove absence — provisioned by this area's qa-contributor-bound-member recipe, exactly as in search.rls-both-personas. ⚠ Its step-5 own-row invoice must carry `owner` set to the member's email: invoice_own_rows reads owner == current_user.email and `owner` has no default, so a member-created invoice without it is invisible to its creator (QA run #23166 A5)" ], "knownGaps": [ "the /_console bundle is vendored and may be stale — verify the palette/page behavior against current objectui app-shell or a fresh build (stale-console-bundle, RUNNER §2)", @@ -709,7 +709,7 @@ "surface": "api", "personas": [ "seeded admin (admin@objectos.ai): the negative control, who sees every object and field", - "signed-up member bound to the contributor position via the qa-contributor-bound-member recipe: a strict subset reader of showcase_invoice (invoice_own_rows), refused some objects outright, and refused the admin-only search columns of sys_user" + "signed-up member bound to the showcase_contributor position via the qa-contributor-bound-member recipe: a strict subset reader of showcase_invoice (invoice_own_rows), refused some objects outright, and refused the admin-only search columns of sys_user" ], "fixtures": { "app": "showcase", diff --git a/docs/qa/platform-checklist/areas/ux-conventions.json b/docs/qa/platform-checklist/areas/ux-conventions.json index 80e8c740105..8c7cfd63250 100644 --- a/docs/qa/platform-checklist/areas/ux-conventions.json +++ b/docs/qa/platform-checklist/areas/ux-conventions.json @@ -48,7 +48,7 @@ "sign in as admin; keep a second context (or the REST API) open to read server truth independently of the UI", "for each variant perform the write once, then within ~2 s capture a screenshot and the DOM of every feedback region — toasts ([data-sonner-toast]), role=status / role=alert regions, and the surface's own save indicator — recording the verbatim text, or its absence", "for each variant read server-side whether the write landed (GET the record, the metadata item or the sys_ row) and record the request's status from the network log", - "repeat the record rows signed in as the member persona, on member-owned records — the stock member's set (showcase_member_default) grants showcase_task read + create only, and the console applies it up front: it offers the member no inline edit, no row selection and no kanban drag, so those three rows cannot be driven as the member (record them as not offered, never as refused). The member's create row runs; for the refusal row use the member's Mark Done on a task record, which answers 403 PERMISSION_DENIED (its error feedback is clause 3's to score). To drive the edit rows as a member, bind the member to the contributor position (showcase_contributor grants task edit, not delete)", + "repeat the record rows signed in as the member persona, on member-owned records — the stock member's set (showcase_member_default) grants showcase_task read + create only, and the console applies it up front: it offers the member no inline edit, no row selection and no kanban drag, so those three rows cannot be driven as the member (record them as not offered, never as refused). The member's create row runs; for the refusal row use the member's Mark Done on a task record, which answers 403 PERMISSION_DENIED (its error feedback is clause 3's to score). To drive the edit rows as a member, bind the member to the showcase_contributor position (showcase_contributor grants task edit, not delete)", "negative pass: (a) submit the create form with a required field empty; then break a validation rule — showcase_task's only rule is the UPDATE-time state machine task_status_flow (no create-time rule exists), so drive it as a refused transition, Backlog → Done, through the API: PATCH /api/v1/data/showcase_task/ID {status: 'done'} on a backlog task answers 400, with the rule's invalid_transition refusal and its message 'Invalid task status transition.' (packages/objectql/src/validation/rule-validator.ts). Record that request's status and error code for acceptance[3]. No console surface can send that write. The record form's status select and the grid's inline editor offer only the values the machine admits from the stored state (ui/packages/plugin-form/src/stateMachineOptions.ts, and plugin-grid through stateMachineNextValues), so Done is not offered from Backlog. Record the narrowed picker as those surfaces' answer, as with the kanban's dimmed lane. ⛔ Not the kanban: the drop gate (#25933, ui/packages/plugin-kanban/src/KanbanImpl.tsx) dims a lane the transition rule blocks (data-kanban-drop-blocked) and a drop there sends no request, so no refusal reaches the server; record the dimmed lane as that surface's answer; (b) open an inline editor and leave it without changing the value", "where a variant offers Undo, click it and read the record back — on stock the import does (knownGaps[3]): run the two-row CSV import as a job, once creating and once updating, then 'Undo import' from the import history — it asks through the browser's native window.confirm (ui/packages/plugin-grid/src/ImportWizard.tsx), unlike Studio discard's in-app dialog, so a driver accepts it as a dialog event — and read the rows back" ], diff --git a/docs/screenshots/render-readme-demo.mjs b/docs/screenshots/render-readme-demo.mjs index 35c22f3b393..6ab291c0023 100644 --- a/docs/screenshots/render-readme-demo.mjs +++ b/docs/screenshots/render-readme-demo.mjs @@ -105,8 +105,8 @@ const APP_ID = 'com.example.showcase'; const OBJECT = 'showcase_team'; // The dev admin `--fresh` seeds (packages/cli `dev --seed-admin`) and the // showcase's Auditor persona (examples/app-showcase/src/security/demo-personas.ts: -// `auditor` position only, so `showcase_auditor` plus the member baseline, which -// grants Team read and no create). Both are dev-only, well-known credentials. +// `showcase_auditor` position only, so the `showcase_auditor` set plus the +// member baseline, which grants Team read and no create). Both are dev-only, well-known credentials. const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; const READ_ONLY = { email: 'auditor.demo@example.com', password: 'showcase123' }; const NEW_TEAM = { name: 'Data Guild', lead: 'ada@example.com', capacity_hours: 120 }; diff --git a/examples/app-crm/src/apps/crm.app.ts b/examples/app-crm/src/apps/crm.app.ts index 493eaf0fabc..bb328d62e3c 100644 --- a/examples/app-crm/src/apps/crm.app.ts +++ b/examples/app-crm/src/apps/crm.app.ts @@ -31,11 +31,11 @@ export const CrmApp = App.create({ icon: 'chart-bar', children: [ // Offered only to a member who can read the opportunities every - // Pipeline widget aggregates (`opportunity_metrics` is bound to + // Pipeline widget aggregates (`crm_opportunity_metrics` is bound to // `crm_opportunity`); anyone else would open a page whose every tile // the server refuses. `visible` hides; the dataset query's own 403 // stays the enforcement (ADR-0066 D4). - { id: 'nav_pipeline_dashboard', type: 'dashboard', dashboardName: 'pipeline_dashboard', label: 'Pipeline', icon: 'layout-dashboard', visible: "current_user.can('crm_opportunity', 'read')" }, + { id: 'nav_pipeline_dashboard', type: 'dashboard', dashboardName: 'crm_pipeline_dashboard', label: 'Pipeline', icon: 'layout-dashboard', visible: "current_user.can('crm_opportunity', 'read')" }, ], }, ], diff --git a/examples/app-crm/src/dashboards/pipeline.dashboard.ts b/examples/app-crm/src/dashboards/pipeline.dashboard.ts index 1d11e1efc47..d51fc21ac3d 100644 --- a/examples/app-crm/src/dashboards/pipeline.dashboard.ts +++ b/examples/app-crm/src/dashboards/pipeline.dashboard.ts @@ -8,7 +8,7 @@ import type { Dashboard } from '@objectstack/spec/ui'; * Demonstrates period-over-period comparison via `compareTo`, whose shape is * `{ kind, dimension? }` — the same contract the analytics executor reads * (`DatasetSelection.compareTo`). Every widget below omits `dimension`: - * `opportunity_metrics` dates exactly one dimension (`close_date`), so the + * `crm_opportunity_metrics` dates exactly one dimension (`close_date`), so the * executor resolves it. Name it explicitly (`{ kind: 'previousYear', dimension: * 'close_date' }`) only on a dataset that dates more than one — the executor * refuses to guess, and says which candidates it found. @@ -29,7 +29,7 @@ import type { Dashboard } from '@objectstack/spec/ui'; * (pie / donut / funnel ignore overlays even if set). */ export const PipelineDashboard: Dashboard = { - name: 'pipeline_dashboard', + name: 'crm_pipeline_dashboard', label: 'Pipeline Dashboard', description: 'The sales pipeline at a glance, compared with earlier periods.', columns: 12, @@ -47,7 +47,7 @@ export const PipelineDashboard: Dashboard = { title: 'Total Pipeline', description: 'Total value of all open opportunities.', filter: { stage: { $nin: ['closed_won', 'closed_lost'] } }, - dataset: 'opportunity_metrics', + dataset: 'crm_opportunity_metrics', values: ['total_amount'], layout: { x: 0, y: 0, w: 4, h: 2 }, }, @@ -64,7 +64,7 @@ export const PipelineDashboard: Dashboard = { }, }, compareTo: { kind: 'previousPeriod' }, - dataset: 'opportunity_metrics', + dataset: 'crm_opportunity_metrics', values: ['total_amount'], layout: { x: 4, y: 0, w: 4, h: 2 }, }, @@ -81,7 +81,7 @@ export const PipelineDashboard: Dashboard = { }, }, compareTo: { kind: 'previousYear' }, - dataset: 'opportunity_metrics', + dataset: 'crm_opportunity_metrics', values: ['avg_amount'], layout: { x: 8, y: 0, w: 4, h: 2 }, }, @@ -96,7 +96,7 @@ export const PipelineDashboard: Dashboard = { close_date: { $gte: '{1_years_ago}', $lte: '{today}' }, }, compareTo: { kind: 'previousYear' }, - dataset: 'opportunity_metrics', + dataset: 'crm_opportunity_metrics', dimensions: ['close_date'], values: ['opp_count'], // Axis fields name the dataset's dimension/measure (NOT base columns) — @@ -115,7 +115,7 @@ export const PipelineDashboard: Dashboard = { }, }, compareTo: { kind: 'previousPeriod' }, - dataset: 'opportunity_metrics', + dataset: 'crm_opportunity_metrics', dimensions: ['stage'], values: ['opp_count'], layout: { x: 8, y: 2, w: 4, h: 4 }, @@ -128,7 +128,7 @@ export const PipelineDashboard: Dashboard = { title: 'Open Pipeline by Stage', description: 'Value of open opportunities, split by stage.', filter: { stage: { $nin: ['closed_won', 'closed_lost'] } }, - dataset: 'opportunity_metrics', + dataset: 'crm_opportunity_metrics', dimensions: ['stage'], values: ['total_amount'], layout: { x: 0, y: 6, w: 6, h: 4 }, diff --git a/examples/app-crm/src/datasets/opportunity.dataset.ts b/examples/app-crm/src/datasets/opportunity.dataset.ts index 106e1d0a560..605f3a3aeaa 100644 --- a/examples/app-crm/src/datasets/opportunity.dataset.ts +++ b/examples/app-crm/src/datasets/opportunity.dataset.ts @@ -11,7 +11,7 @@ import { defineDataset } from '@objectstack/spec/ui'; * same thing on every tile. Single-object dataset (no `include`). */ export const OpportunityDataset = defineDataset({ - name: 'opportunity_metrics', + name: 'crm_opportunity_metrics', label: 'Opportunity Metrics', description: 'Semantic layer for sales-pipeline counts and amounts', object: 'crm_opportunity', diff --git a/examples/app-crm/src/hooks/opportunity.hook.ts b/examples/app-crm/src/hooks/opportunity.hook.ts index 40131dacf5e..4eeb6a9e70f 100644 --- a/examples/app-crm/src/hooks/opportunity.hook.ts +++ b/examples/app-crm/src/hooks/opportunity.hook.ts @@ -7,7 +7,7 @@ import { defineHook, type HookContext } from '@objectstack/spec/data'; * and 0% when Closed Lost. Demonstrates a basic before-update hook. */ export const OpportunityStageHook = defineHook({ - name: 'opportunity_stage_probability', + name: 'crm_opportunity_stage_probability', object: 'crm_opportunity', events: ['beforeInsert', 'beforeUpdate'], priority: 100, diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index 8f228d43739..c9ca897376a 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -10,14 +10,14 @@ import { definePermissionSet } from '@objectstack/spec/security'; * authorization resolver reads. */ export const SalesRepPosition = definePosition({ - name: 'sales_rep', + name: 'crm_sales_rep', label: 'Sales Representative', description: 'Front-line sales representative.', permissionSets: ['crm_sales_user'], }); export const SalesManagerPosition = definePosition({ - name: 'sales_manager', + name: 'crm_sales_manager', label: 'Sales Manager', description: 'Manages a team of sales reps.', permissionSets: ['crm_sales_user'], @@ -25,12 +25,12 @@ export const SalesManagerPosition = definePosition({ /** * Registered in `objectstack.config.ts` (`positions`) and distributing the - * `crm_sales_user` permission set, alongside `sales_rep` and `sales_manager`. + * `crm_sales_user` permission set, alongside `crm_sales_rep` and `crm_sales_manager`. * This app models no approval flow; the worked approval flows live in * `examples/app-showcase/src/automation/flows/`. */ export const FinanceApproverPosition = definePosition({ - name: 'finance_approver', + name: 'crm_finance_approver', label: 'Finance Approver', description: 'Finance team member authorised to approve discounts above 30%.', permissionSets: ['crm_sales_user'], diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index 9ef6489195c..e5ac5c6fb00 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -333,7 +333,7 @@ export const CrmTranslationBundle = defineTranslationBundle({ }, }, dashboards: { - pipeline_dashboard: { + crm_pipeline_dashboard: { label: '销售漏斗', description: '销售漏斗一览,并与之前的周期对比。', widgets: { @@ -366,7 +366,7 @@ export const CrmTranslationBundle = defineTranslationBundle({ }, // The names a dashboard draws under its tiles and on its chart axes. datasets: { - opportunity_metrics: { + crm_opportunity_metrics: { label: '商机指标', description: '销售漏斗的商机数与金额。', dimensions: { @@ -411,9 +411,9 @@ export const CrmTranslationBundle = defineTranslationBundle({ // The security catalog: what an admin assigns a person to, and what // Setup lists under it. positions: { - sales_rep: { label: '销售代表', description: '一线销售代表。' }, - sales_manager: { label: '销售经理', description: '管理一支销售代表团队。' }, - finance_approver: { label: '财务审批人', description: '有权审批 30% 以上折扣的财务团队成员。' }, + crm_sales_rep: { label: '销售代表', description: '一线销售代表。' }, + crm_sales_manager: { label: '销售经理', description: '管理一支销售代表团队。' }, + crm_finance_approver: { label: '财务审批人', description: '有权审批 30% 以上折扣的财务团队成员。' }, }, permissions: { crm_sales_user: { label: 'CRM 销售用户' }, diff --git a/examples/app-crm/test/opportunity-stage-hook.test.ts b/examples/app-crm/test/opportunity-stage-hook.test.ts index e509ad8839f..22c6fe1fb5d 100644 --- a/examples/app-crm/test/opportunity-stage-hook.test.ts +++ b/examples/app-crm/test/opportunity-stage-hook.test.ts @@ -273,7 +273,7 @@ describe('#7258 — app-crm `OpportunityStageHook` moves the persisted probabili hooks?: Array<{ name?: string; object?: string; events?: string[] }>; }; const registered = (stack.hooks ?? []).find((h) => h.name === OpportunityStageHook.name); - expect(registered, '`opportunity_stage_probability` must be in defineStack({ hooks })').toBeDefined(); + expect(registered, '`crm_opportunity_stage_probability` must be in defineStack({ hooks })').toBeDefined(); expect(registered!.object).toBe('crm_opportunity'); expect(registered!.events).toEqual(expect.arrayContaining(['beforeInsert', 'beforeUpdate'])); }); diff --git a/examples/app-crm/test/pipeline-audience.test.ts b/examples/app-crm/test/pipeline-audience.test.ts index 22868cb5bb4..f534594e887 100644 --- a/examples/app-crm/test/pipeline-audience.test.ts +++ b/examples/app-crm/test/pipeline-audience.test.ts @@ -38,18 +38,18 @@ const READ_PREDICATE = /^current_user\.can\('([a-z0-9_]+)', 'read'\)$/; const app = (stack.apps ?? []).find((a) => a.name === 'crm_app'); const navEntry = findNav(app?.navigation as NavEntry[] | undefined, 'nav_pipeline_dashboard'); -const dashboard = (stack.dashboards ?? []).find((d) => d.name === 'pipeline_dashboard'); +const dashboard = (stack.dashboards ?? []).find((d) => d.name === 'crm_pipeline_dashboard'); // The stack parse normalises an authored CEL string to `{ dialect, source }`. const predicate = navEntry?.visible; describe('Pipeline nav entry audience (#27735)', () => { it('carries a read predicate on one object', () => { - expect(navEntry).toMatchObject({ type: 'dashboard', dashboardName: 'pipeline_dashboard' }); + expect(navEntry).toMatchObject({ type: 'dashboard', dashboardName: 'crm_pipeline_dashboard' }); expect(predicate?.dialect).toBe('cel'); expect(predicate?.source).toMatch(READ_PREDICATE); }); - it('names the object of the dataset every pipeline_dashboard widget reads', () => { + it('names the object of the dataset every crm_pipeline_dashboard widget reads', () => { const named = predicate?.source?.match(READ_PREDICATE)?.[1]; expect(named).toBeDefined(); const widgets = (dashboard?.widgets ?? []) as Array<{ dataset?: string }>; diff --git a/examples/app-crm/test/position-bindings.test.ts b/examples/app-crm/test/position-bindings.test.ts index 68ea7cead93..d29c793225e 100644 --- a/examples/app-crm/test/position-bindings.test.ts +++ b/examples/app-crm/test/position-bindings.test.ts @@ -16,9 +16,9 @@ describe('CRM position bindings', () => { .flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)) .sort(); expect(declared).toEqual([ - 'finance_approver -> crm_sales_user', - 'sales_manager -> crm_sales_user', - 'sales_rep -> crm_sales_user', + 'crm_finance_approver -> crm_sales_user', + 'crm_sales_manager -> crm_sales_user', + 'crm_sales_rep -> crm_sales_user', ]); }); }); diff --git a/examples/app-crm/test/smoke.test.ts b/examples/app-crm/test/smoke.test.ts index 69a80119b64..0fe9bfd67e9 100644 --- a/examples/app-crm/test/smoke.test.ts +++ b/examples/app-crm/test/smoke.test.ts @@ -189,7 +189,7 @@ describe('Pipeline dashboard', () => { it('uses `compareTo: { kind: previousPeriod }` for the current-quarter KPI', () => { const w: any = byId.get('won_this_quarter'); // #5011: `compareTo` is the executor's own `{ kind, dimension? }` shape. - // `dimension` is omitted deliberately — `opportunity_metrics` dates exactly + // `dimension` is omitted deliberately — `crm_opportunity_metrics` dates exactly // one dimension, so the executor resolves it (and would error, naming the // candidates, if it could not). expect(w.compareTo).toEqual({ kind: 'previousPeriod' }); @@ -227,7 +227,7 @@ describe('Pipeline dashboard', () => { it('widgets bind to the opportunity dataset', () => { // ADR-0021 single-form: widgets reference the semantic dataset, not a raw object. for (const w of PipelineDashboard.widgets) { - expect((w as any).dataset).toBe('opportunity_metrics'); + expect((w as any).dataset).toBe('crm_opportunity_metrics'); } }); @@ -253,7 +253,7 @@ describe('Pipeline dashboard schema validation', () => { it('passes the DashboardSchema zod parser end-to-end', async () => { const { DashboardSchema } = await import('@objectstack/spec/ui'); const parsed = DashboardSchema.parse(PipelineDashboard); - expect(parsed.name).toBe('pipeline_dashboard'); + expect(parsed.name).toBe('crm_pipeline_dashboard'); expect(parsed.widgets).toHaveLength(6); }); }); diff --git a/examples/app-multi-package/README.md b/examples/app-multi-package/README.md index 721be701dcf..60afe07cea7 100644 --- a/examples/app-multi-package/README.md +++ b/examples/app-multi-package/README.md @@ -6,7 +6,7 @@ namespace, so the product splits into modules without renaming a single object. | package | type | namespace | owns | | --- | --- | --- | --- | -| `com.example.multi.core` | `app` | `crm` | `crm_account`, the `multi_crm` app | +| `com.example.multi.core` | `app` | `crm` | `crm_account`, the `crm_multi_crm` app | | `com.example.multi.orders` | `module` | `crm` | `crm_order` (lookup → `crm_account`) | ```bash diff --git a/examples/app-multi-package/src/packages/core/index.ts b/examples/app-multi-package/src/packages/core/index.ts index 67d5ef6fc2d..ffc9c415074 100644 --- a/examples/app-multi-package/src/packages/core/index.ts +++ b/examples/app-multi-package/src/packages/core/index.ts @@ -40,7 +40,7 @@ export default defineStack({ apps: [ { - name: 'multi_crm', + name: 'crm_multi_crm', label: 'Multi-Package CRM', description: 'Accounts, plus whatever modules this artifact delivers alongside', // The group is a CONTAINER this package owns and modules aim at diff --git a/examples/app-multi-package/src/packages/orders/index.ts b/examples/app-multi-package/src/packages/orders/index.ts index 0ad7a0b3ea5..207e7d242da 100644 --- a/examples/app-multi-package/src/packages/orders/index.ts +++ b/examples/app-multi-package/src/packages/orders/index.ts @@ -69,7 +69,7 @@ export default defineStack({ // app, so it travels with the package identity. navigationContributions: [ { - app: 'multi_crm', + app: 'crm_multi_crm', group: 'sales_group', items: [ { id: 'nav_orders', type: 'object', objectName: 'crm_order', label: 'Orders', icon: 'shopping-cart' }, diff --git a/examples/app-showcase/src/automation/flows/approver-bindings.flow.ts b/examples/app-showcase/src/automation/flows/approver-bindings.flow.ts index e4ef4d47231..c76bd972135 100644 --- a/examples/app-showcase/src/automation/flows/approver-bindings.flow.ts +++ b/examples/app-showcase/src/automation/flows/approver-bindings.flow.ts @@ -109,8 +109,8 @@ export const ApproverBindingsFlow = defineFlow({ config: { // Commits the position's machine NAME, not its row id: the engine routes // on `sys_user_position.position`, and a machine name is what stays - // valid across environments. `manager` is a showcase position. - approvers: [{ type: 'position', value: 'manager' }], + // valid across environments. `showcase_manager` is a showcase position. + approvers: [{ type: 'position', value: 'showcase_manager' }], behavior: 'first_response', }, }, diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 9f537eab2ae..8ef2368e1a8 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -253,7 +253,7 @@ export const BudgetApprovalFlow = defineFlow({ type: 'approval', label: 'Manager Review', config: { - approvers: [{ type: 'position', value: 'manager' }], + approvers: [{ type: 'position', value: 'showcase_manager' }], behavior: 'first_response', // Deliberately UNLOCKED, and the counterpart to `exec_review` below — // together they dogfood both record-lock policies in one flow @@ -295,7 +295,7 @@ export const BudgetApprovalFlow = defineFlow({ type: 'approval', label: 'Executive Review', config: { - approvers: [{ type: 'position', value: 'exec' }], + approvers: [{ type: 'position', value: 'showcase_exec' }], behavior: 'unanimous', // Locked, unlike `manager_review` — a multi-approver sign-off must // decide on a stable record, so edits are refused until it completes. @@ -818,7 +818,7 @@ export const ClosureSignoffSubflow = defineFlow({ type: 'approval', label: 'Manager Sign-off', config: { - approvers: [{ type: 'position', value: 'manager' }], + approvers: [{ type: 'position', value: 'showcase_manager' }], behavior: 'first_response', // The parent project just hit a terminal status — no point locking it. lockRecord: false, @@ -1329,8 +1329,8 @@ export const ResilientSyncFlow = defineFlow({ * discouraged: the `approval` node declares `resumeAuthority: 'service'`, so * the generic run-resume route answers 403 for a run parked on one (#3801). * POST /api/v1/automation/showcase_invoice_signoff/runs/{runId}/resume ← 403 - * POST /api/v1/approvals/requests/{id}/approve { actorId: 'position:finance' } - * POST /api/v1/approvals/requests/{id}/approve { actorId: 'position:legal' } ← now it continues + * POST /api/v1/approvals/requests/{id}/approve { actorId: 'position:showcase_finance' } + * POST /api/v1/approvals/requests/{id}/approve { actorId: 'position:showcase_legal' } ← now it continues */ export const InvoiceDualSignoffFlow = defineFlow({ name: 'showcase_invoice_signoff', @@ -1367,8 +1367,8 @@ export const InvoiceDualSignoffFlow = defineFlow({ config: { // Two approver groups, notified in parallel; `unanimous` waits for both. approvers: [ - { type: 'position', value: 'finance' }, - { type: 'position', value: 'legal' }, + { type: 'position', value: 'showcase_finance' }, + { type: 'position', value: 'showcase_legal' }, ], behavior: 'unanimous', // The invoice keeps flowing through other automations while it waits. @@ -1567,7 +1567,7 @@ export const OneTaskSignoffSubflow = defineFlow({ type: 'approval', label: 'Task Sign-off', config: { - approvers: [{ type: 'position', value: 'manager' }], + approvers: [{ type: 'position', value: 'showcase_manager' }], behavior: 'first_response', lockRecord: false, }, @@ -1865,8 +1865,8 @@ export const ExpenseSignoffFlow = defineFlow({ label: 'Manager + Finance Sign-off', config: { approvers: [ - { type: 'position', value: 'manager', group: 'manager' }, - { type: 'position', value: 'auditor', group: 'finance' }, + { type: 'position', value: 'showcase_manager', group: 'manager' }, + { type: 'position', value: 'showcase_auditor', group: 'finance' }, ], behavior: 'per_group', minApprovals: 1, @@ -1943,9 +1943,9 @@ export const CommitteeQuorumFlow = defineFlow({ label: 'Committee Sign-off (2 of 3)', config: { approvers: [ - { type: 'position', value: 'manager' }, - { type: 'position', value: 'finance' }, - { type: 'position', value: 'legal' }, + { type: 'position', value: 'showcase_manager' }, + { type: 'position', value: 'showcase_finance' }, + { type: 'position', value: 'showcase_legal' }, ], behavior: 'quorum', minApprovals: 2, diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index d17e177af78..8b62a455853 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -236,7 +236,7 @@ const businessUnits = defineSeed(BusinessUnit, { * actually evaluates against: * • depth grants (`readScope: 'unit' / 'unit_and_below'`) resolve membership * through it (Setup → Access Control → Business Units, org-chart view); - * • the `share_new_inquiries_with_field_ops` sharing rule expands the + * • the `showcase_share_new_inquiries_with_field_ops` sharing rule expands the * `bu_field_ops` SUBTREE (Field Operations + West/East Coast); * • the `showcase_field_ops_delegate` adminScope is bounded by it. * diff --git a/examples/app-showcase/src/docs/showcase_tour_security.md b/examples/app-showcase/src/docs/showcase_tour_security.md index d2436ba8a66..b37ba702407 100644 --- a/examples/app-showcase/src/docs/showcase_tour_security.md +++ b/examples/app-showcase/src/docs/showcase_tour_security.md @@ -51,7 +51,7 @@ The other sets each demonstrate one axis: - `showcase_guest_portal` — guest-safe capability (read announcements, create inquiries) shaped for the built-in **`guest`** position (ADR-0090 D9). No door binds a set to `guest` today (ADR-0138 D3), so the showcase - distributes it through the `client_portal_user` position instead. + distributes it through the `showcase_client_portal_user` position instead. - `showcase_field_ops_delegate` — **delegated administration** (ADR-0090 D12): an `adminScope` bounded to the Field Operations subtree with an assignable-set allowlist; no self-escalation. @@ -98,8 +98,8 @@ wildcard VAMA, high-privilege anchor suggestions, forbidden vocabulary). Log in as a non-admin member (create one in Setup → Users): the Field Zoo's permission-gated fields mask, private notes vanish, and write attempts -outside your row scope are rejected — while a user assigned the `auditor` -position reads everything and the Setup app appears for `ops` holders. +outside your row scope are rejected — while a user assigned the `showcase_auditor` +position reads everything and the Setup app appears for `showcase_ops` holders. Ask "why" with the explain engine — the `security` service's `explain({ object, operation, userId })` reports every layer's verdict with contributor attribution, walking the same code the middleware diff --git a/examples/app-showcase/src/security/demo-personas.ts b/examples/app-showcase/src/security/demo-personas.ts index ca8e8108a38..bba4cba4499 100644 --- a/examples/app-showcase/src/security/demo-personas.ts +++ b/examples/app-showcase/src/security/demo-personas.ts @@ -123,7 +123,7 @@ export const PHONE_DEMO_USER = { } as const; /** - * A second persona holding ONLY `auditor`, which is the position behind the + * A second persona holding ONLY `showcase_auditor`, which is the position behind the * `finance` group of the per-group (会签) demo. It has to be a *different* user * from the admin: with one user in both groups a single decision would satisfy * both tallies at once, and "one approval per group" would never be observable. diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 3caf89dd75e..7a8ba07adec 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -73,7 +73,7 @@ export const ContributorPermissionSet = definePermissionSet({ // // No policy below lists `positions`, deliberately. A set's row-level policies // are part of what the set grants, so they hold for EVERY holder of the set, - // whichever way it is held: through the `contributor` position's declared + // whichever way it is held: through the `showcase_contributor` position's declared // `permissionSets` (positions.ts) or granted directly (the delegated admin below may // hand this set out). `positions` narrows a policy to callers holding one of // the listed positions (ADR-0090 P2) — a tool for a policy on a baseline set @@ -153,7 +153,7 @@ export const ContributorPermissionSet = definePermissionSet({ * the open edition fails closed to owner-only, so this open example does not * author them (declared ≠ enforced is the one thing a showcase must never * do). BUSINESS-UNIT-shaped visibility in the open edition is instead - * demonstrated by the `share_new_inquiries_with_field_ops` sharing rule + * demonstrated by the `showcase_share_new_inquiries_with_field_ops` sharing rule * (BU-subtree recipient — see sharing-rules.ts). */ export const ManagerPermissionSet = definePermissionSet({ @@ -382,7 +382,7 @@ export const MemberDefaultPermissionSet = definePermissionSet({ * No door runs that tier yet, because no door binds a set to `guest`: a save * of the packaged `guest` position answers 403 `NOT_OVERRIDABLE` (ADR-0138 D3 * item 5), and the tier check belongs to the bind door when it is built (item - * 4). Here the set reaches people through the `client_portal_user` position. + * 4). Here the set reaches people through the `showcase_client_portal_user` position. */ export const GuestPortalPermissionSet = definePermissionSet({ name: 'showcase_guest_portal', diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index 24299bab725..2d798f18e77 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -25,7 +25,7 @@ import { definePosition } from '@objectstack/spec/identity'; /** Works tasks on their own projects — the rank-and-file position. */ export const ContributorPosition = definePosition({ - name: 'contributor', + name: 'showcase_contributor', label: 'Contributor', description: 'Works tasks on their own projects.', permissionSets: ['showcase_contributor'], @@ -33,7 +33,7 @@ export const ContributorPosition = definePosition({ /** Runs a unit: depth-scoped visibility over the unit's private records. */ export const ManagerPosition = definePosition({ - name: 'manager', + name: 'showcase_manager', label: 'Project Manager', description: 'Manages projects and the contributors on them.', permissionSets: ['showcase_manager'], @@ -41,7 +41,7 @@ export const ManagerPosition = definePosition({ /** Org-wide read for reporting — depth-based (`readScope: org`), not VAMA. */ export const ExecPosition = definePosition({ - name: 'exec', + name: 'showcase_exec', label: 'Executive', description: 'Read-all visibility for reporting.', permissionSets: ['showcase_executive'], @@ -49,7 +49,7 @@ export const ExecPosition = definePosition({ /** Compliance: View-All bypass (VAMA) — reads everything, changes nothing. */ export const AuditorPosition = definePosition({ - name: 'auditor', + name: 'showcase_auditor', label: 'Auditor', description: 'Compliance read-only view across private records (viewAllRecords).', permissionSets: ['showcase_auditor'], @@ -57,7 +57,7 @@ export const AuditorPosition = definePosition({ /** Back-office: system permissions, Modify-All repairs, the Operations app. */ export const OpsPosition = definePosition({ - name: 'ops', + name: 'showcase_ops', label: 'Operations', description: 'Back-office operations — Setup access, announcement repairs, Operations app.', permissionSets: ['showcase_ops'], @@ -69,7 +69,7 @@ export const OpsPosition = definePosition({ * set (`adminScope`); this position is just how an admin hands it out. */ export const FieldOpsDelegatePosition = definePosition({ - name: 'field_ops_delegate', + name: 'showcase_field_ops_delegate', label: 'Field Ops Delegate Admin', description: 'Scoped administration of the Field Operations business-unit subtree.', permissionSets: ['showcase_field_ops_delegate'], @@ -82,7 +82,7 @@ export const FieldOpsDelegatePosition = definePosition({ * bound set masks the three budget figures outright. */ export const ClientLiaisonPosition = definePosition({ - name: 'client_liaison', + name: 'showcase_client_liaison', label: 'Client Liaison', description: 'Prepares and publishes client-facing project briefs; cannot see internal budget figures.', permissionSets: ['showcase_client_liaison'], @@ -95,7 +95,7 @@ export const ClientLiaisonPosition = definePosition({ * marks a user as belonging to that audience. */ export const ClientPortalUserPosition = definePosition({ - name: 'client_portal_user', + name: 'showcase_client_portal_user', label: 'Client Portal User', description: 'External client admitted to the Client Portal.', permissionSets: ['showcase_guest_portal'], @@ -103,7 +103,7 @@ export const ClientPortalUserPosition = definePosition({ /** * Approval-routing positions (会签 / quorum demos). The `approval` flow nodes - * in src/automation/flows route to `{ type: 'position', value: 'finance' | 'legal' }` + * in src/automation/flows route to `{ type: 'position', value: 'showcase_finance' | 'showcase_legal' }` * (Invoice Dual Sign-off → finance AND legal; High-Value Committee Quorum → * manager + finance + legal, 2-of-3). Without these declared — and without a * holder assigned (see src/security/seed-approval-demo.ts) — those requests @@ -114,13 +114,13 @@ export const ClientPortalUserPosition = definePosition({ * so a holder gets no extra data access from holding one. */ export const FinancePosition = definePosition({ - name: 'finance', + name: 'showcase_finance', label: 'Finance', description: 'Finance sign-off authority on invoices and high-value expenses (approval routing only).', }); export const LegalPosition = definePosition({ - name: 'legal', + name: 'showcase_legal', label: 'Legal', description: 'Legal sign-off authority on invoices and high-value expenses (approval routing only).', }); diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index 74ff671836d..46d04ac3026 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -7,8 +7,8 @@ * demonstrable on a FRESH boot, with no manual setup. * * Why this exists (and can't be a seed): - * - The `approval` flow nodes route to `{ type: 'position', value: 'finance' | - * 'legal' | 'manager' }`. Approver resolution reads `sys_user_position` + * - The `approval` flow nodes route to `{ type: 'position', value: + * 'showcase_finance' | 'showcase_legal' | 'showcase_manager' }`. Approver resolution reads `sys_user_position` * (ADR-0090 D3), but users can't be seeded (they sign up) and position * assignments are runtime admin actions — so out of the box NO ONE holds * those positions and every request resolves to an empty slate and waits @@ -21,7 +21,7 @@ * So we play the admin's part imperatively: on `kernel:bootstrapped` (after * every `kernel:ready` handler has settled and the automation engine is * wired) we - * 1. assign the dev-seeded admin to `manager` / `finance` / `legal` so they + * 1. assign the dev-seeded admin to `showcase_manager` / `showcase_finance` / `showcase_legal` so they * are a resolvable approver on every demo request (and can act in the * inbox); * 2. provision the two demo personas — a phone-based user so the "phone @@ -60,13 +60,14 @@ const SYS = { isSystem: true } as const; /** * Positions the admin is granted so they resolve as an approver on the demos. * Covers every `{ type: 'position' }` the showcase approval flows route to and - * expect the admin to hold — including `exec`, the SECOND tier of - * `showcase_budget_approval` (manager → exec). Without `exec` a budget approval - * that a user drives to step 2 routes to an unstaffed position and dead-ends + * expect the admin to hold — including `showcase_exec`, the SECOND tier of + * `showcase_budget_approval` (`showcase_manager` → `showcase_exec`). Without + * `showcase_exec` a budget approval that a user drives to step 2 routes to an + * unstaffed position and dead-ends * (framework#3424): undecidable request, record locked. Keep this in sync with * the position values authored in `automation/flows`. */ -const ADMIN_APPROVAL_POSITIONS = ['manager', 'finance', 'legal', 'exec'] as const; +const ADMIN_APPROVAL_POSITIONS = ['showcase_manager', 'showcase_finance', 'showcase_legal', 'showcase_exec'] as const; interface ApprovalDemoContext { ql: { @@ -377,10 +378,10 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { // requester who is never also one of her own approvers. const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER, organizationId)) ?? null; // The auditor persona backs the `finance` group of the per-group demo. It - // deliberately holds ONLY `auditor`, so the two groups have distinct + // deliberately holds ONLY `showcase_auditor`, so the two groups have distinct // holders and the request stays open until each group has answered. const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER, organizationId); - if (auditorId) await assignPositions(ctx, auditorId, ['auditor'], organizationId, 'auditor'); + if (auditorId) await assignPositions(ctx, auditorId, ['showcase_auditor'], organizationId, 'auditor'); // [#9308 fixture 1] Make both personas SIGN-INABLE. Provisioning them as // rows was never the hard half — the credential account was. diff --git a/examples/app-showcase/src/security/sharing-rules.ts b/examples/app-showcase/src/security/sharing-rules.ts index 4053da19353..29e53274d68 100644 --- a/examples/app-showcase/src/security/sharing-rules.ts +++ b/examples/app-showcase/src/security/sharing-rules.ts @@ -68,7 +68,7 @@ import { defineSharingRule } from '@objectstack/spec/security'; */ export const NewInquiryFieldOpsRule = defineSharingRule({ type: 'criteria', - name: 'share_new_inquiries_with_field_ops', + name: 'showcase_share_new_inquiries_with_field_ops', label: 'New Inquiries → Field Operations (BU subtree)', description: 'Share incoming (status=new) inquiries with the Field Operations business-unit subtree for triage.', @@ -102,14 +102,14 @@ export const NewInquiryFieldOpsRule = defineSharingRule({ */ export const KeyAccountQualifiedContactRule = defineSharingRule({ type: 'criteria', - name: 'share_key_account_qualified_contacts_with_managers', + name: 'showcase_share_key_account_qualified_contacts_with_managers', label: 'Key-Account Qualified Contacts → Managers', description: 'Share qualified contacts at the key account with managers (compound condition, ADR-0058 D3).', object: 'showcase_contact', condition: "record.stage == 'qualified' && record.company == 'Northwind'", accessLevel: 'read', - sharedWith: { type: 'position', value: 'manager' }, + sharedWith: { type: 'position', value: 'showcase_manager' }, active: true, }); diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index a30c36a757f..417eef60643 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -1372,16 +1372,16 @@ export const ShowcaseTranslationBundle = { // `check-i18n-coverage` like every other declared label: a new position or // set that skips zh-CN widens the debt. positions: { - contributor: { label: '贡献者', description: '处理自己项目中的任务。' }, - manager: { label: '项目经理', description: '管理项目及其中的贡献者。' }, - exec: { label: '高管', description: '用于报表的全量只读可见性。' }, - auditor: { label: '审计员', description: '跨私有记录的合规只读视图(viewAllRecords)。' }, - ops: { label: '运营', description: '后台运营 — Setup 访问、公告修复、运营应用。' }, - field_ops_delegate: { label: '现场运营委派管理员', description: '对现场运营业务单元子树的范围化管理。' }, - client_liaison: { label: '客户联络人', description: '准备并发布面向客户的项目简报;看不到内部预算数据。' }, - client_portal_user: { label: '客户门户用户', description: '获准进入客户门户的外部客户。' }, - finance: { label: '财务', description: '发票和大额费用的财务签批权(仅用于审批路由)。' }, - legal: { label: '法务', description: '发票和大额费用的法务签批权(仅用于审批路由)。' }, + showcase_contributor: { label: '贡献者', description: '处理自己项目中的任务。' }, + showcase_manager: { label: '项目经理', description: '管理项目及其中的贡献者。' }, + showcase_exec: { label: '高管', description: '用于报表的全量只读可见性。' }, + showcase_auditor: { label: '审计员', description: '跨私有记录的合规只读视图(viewAllRecords)。' }, + showcase_ops: { label: '运营', description: '后台运营 — Setup 访问、公告修复、运营应用。' }, + showcase_field_ops_delegate: { label: '现场运营委派管理员', description: '对现场运营业务单元子树的范围化管理。' }, + showcase_client_liaison: { label: '客户联络人', description: '准备并发布面向客户的项目简报;看不到内部预算数据。' }, + showcase_client_portal_user: { label: '客户门户用户', description: '获准进入客户门户的外部客户。' }, + showcase_finance: { label: '财务', description: '发票和大额费用的财务签批权(仅用于审批路由)。' }, + showcase_legal: { label: '法务', description: '发票和大额费用的法务签批权(仅用于审批路由)。' }, }, permissions: { showcase_contributor: { label: '示例贡献者' }, diff --git a/examples/app-showcase/test/approval-resume-relation-expand.test.ts b/examples/app-showcase/test/approval-resume-relation-expand.test.ts index 9761752bc1d..68cff67b25a 100644 --- a/examples/app-showcase/test/approval-resume-relation-expand.test.ts +++ b/examples/app-showcase/test/approval-resume-relation-expand.test.ts @@ -472,12 +472,12 @@ describe('#7381 — approving Invoice Dual Sign-off delivers the cleared notice' // One `unanimous` slot each, through the actor ids the flow's own docblock // documents. The first must NOT release the run. const first = await booted.approvals.decide( - request.id, { decision: 'approve', actorId: 'position:finance' }, SYSTEM_CTX, + request.id, { decision: 'approve', actorId: 'position:showcase_finance' }, SYSTEM_CTX, ); expect(first.finalized, 'a unanimous step finalized on one of two approvals').toBe(false); const second = await booted.approvals.decide( - request.id, { decision: 'approve', actorId: 'position:legal' }, SYSTEM_CTX, + request.id, { decision: 'approve', actorId: 'position:showcase_legal' }, SYSTEM_CTX, ); expect(second.finalized).toBe(true); // THE REGRESSION: pre-fix this came back false, with the strand error. @@ -598,14 +598,14 @@ describe('#7381 — approving Invoice Dual Sign-off delivers the cleared notice' const request = await pendingRequest(booted.data); const first = await booted.approvals.decide( - request.id, { decision: 'approve', actorId: 'position:finance' }, SYSTEM_CTX, + request.id, { decision: 'approve', actorId: 'position:showcase_finance' }, SYSTEM_CTX, ); expect(first.finalized).toBe(false); let strand = ''; try { const second = await booted.approvals.decide( - request.id, { decision: 'approve', actorId: 'position:legal' }, SYSTEM_CTX, + request.id, { decision: 'approve', actorId: 'position:showcase_legal' }, SYSTEM_CTX, ); // Whichever way the service reports it, the run must NOT have resumed. expect(second.resumed).not.toBe(true); diff --git a/examples/app-showcase/test/expense-approval-write-back.test.ts b/examples/app-showcase/test/expense-approval-write-back.test.ts index 1ec2d62b2b9..a30c48fa19e 100644 --- a/examples/app-showcase/test/expense-approval-write-back.test.ts +++ b/examples/app-showcase/test/expense-approval-write-back.test.ts @@ -156,8 +156,8 @@ const CASES = [ // Under the $5,000 committee threshold, as the seeded EXP-2001 is. amount: 1500, // per_group: one from EACH group finalizes. - approvers: ['position:manager', 'position:auditor'], - rejecter: 'position:auditor', + approvers: ['position:showcase_manager', 'position:showcase_auditor'], + rejecter: 'position:showcase_auditor', }, { flow: CommitteeQuorumFlow, @@ -165,8 +165,8 @@ const CASES = [ // At or above the $5,000 the committee's start gate requires (EXP-DEMO: 8,900). amount: 8900, // quorum: any 2 of 3 finalizes. - approvers: ['position:manager', 'position:legal'], - rejecter: 'position:finance', + approvers: ['position:showcase_manager', 'position:showcase_legal'], + rejecter: 'position:showcase_finance', }, ] as const; diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 821f8213fe1..3718e063918 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -712,14 +712,14 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 // red here, not a silently narrower dogfood boot. const declared = positions.flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)).sort(); expect(declared).toEqual([ - 'auditor -> showcase_auditor', - 'client_liaison -> showcase_client_liaison', - 'client_portal_user -> showcase_guest_portal', - 'contributor -> showcase_contributor', - 'exec -> showcase_executive', - 'field_ops_delegate -> showcase_field_ops_delegate', - 'manager -> showcase_manager', - 'ops -> showcase_ops', + 'showcase_auditor -> showcase_auditor', + 'showcase_client_liaison -> showcase_client_liaison', + 'showcase_client_portal_user -> showcase_guest_portal', + 'showcase_contributor -> showcase_contributor', + 'showcase_exec -> showcase_executive', + 'showcase_field_ops_delegate -> showcase_field_ops_delegate', + 'showcase_manager -> showcase_manager', + 'showcase_ops -> showcase_ops', ]); }); diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index caec0c3ba35..74babc14054 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -184,11 +184,11 @@ describe('showcase stack', () => { // Four dataset-bound analytics reports: summary, chart, matrix, joined. expect((stack.reports ?? []).length).toBe(4); expect((stack.flows ?? []).length).toBeGreaterThan(0); - // Ten flat positions (contributor/manager/exec/auditor/ops/ - // field_ops_delegate/client_liaison/client_portal_user, plus finance/legal - // for the v16 approval sign-off flows) — the ADR-0090 distribution layer; + // Ten flat positions, each `showcase_`-prefixed (contributor/manager/exec/ + // auditor/ops/field_ops_delegate/client_liaison/client_portal_user, plus + // finance/legal for the v16 approval sign-off flows) — the ADR-0090 distribution layer; // `everyone` and `guest` are built-in anchors and never declared by the app. - // `client_liaison` arrived with the FLS read-mask fixture (#9308 fixture 4): + // `showcase_client_liaison` arrived with the FLS read-mask fixture (#9308 fixture 4): // it is the holder of the app's only `readable: false` grant. expect((stack.positions ?? []).length).toBe(10); expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none diff --git a/examples/app-todo/objectstack.config.ts b/examples/app-todo/objectstack.config.ts index 6d472e86308..70fd0ec3b9f 100644 --- a/examples/app-todo/objectstack.config.ts +++ b/examples/app-todo/objectstack.config.ts @@ -78,7 +78,7 @@ export default defineStack({ // takes `input`, RETURNS a value, and a later declarative node persists it // (#4396), which is why none of these declares an `effect`. // - // `computeNextTaskDueDate` is what makes `task_completion`'s recurrence branch + // `computeNextTaskDueDate` is what makes `todo_task_completion`'s recurrence branch // work: no flow node evaluates a value-producing expression, so the next due // date has to be computed before `create_next_task` runs (#7037). functions: todoFunctions, diff --git a/examples/app-todo/src/actions/register-handlers.ts b/examples/app-todo/src/actions/register-handlers.ts index 299c1c78f76..b8aa3b7b2fd 100644 --- a/examples/app-todo/src/actions/register-handlers.ts +++ b/examples/app-todo/src/actions/register-handlers.ts @@ -87,7 +87,7 @@ export function registerTaskActionHandlers(engine: { engine.registerAction('todo_task', 'exportTasksToCSV', exportTasksToCSV); // ─── Param-collecting script actions ─────────────────────────────── - // `defer_task` / `set_reminder` declare `params`, so the runner collects + // `todo_defer_task` / `todo_set_reminder` declare `params`, so the runner collects // them in a dialog and these handlers run with the values. They were // `type: 'modal'` until #3959 — a modal action has no server dispatch, so // the targets named modal pages that did not exist and neither handler had diff --git a/examples/app-todo/src/actions/task.actions.ts b/examples/app-todo/src/actions/task.actions.ts index 6ae37b74c34..777c4d8eb3e 100644 --- a/examples/app-todo/src/actions/task.actions.ts +++ b/examples/app-todo/src/actions/task.actions.ts @@ -4,7 +4,7 @@ import { defineAction } from '@objectstack/spec/ui'; /** Mark Task as Complete */ export const CompleteTaskAction = defineAction({ - name: 'complete_task', + name: 'todo_complete_task', label: 'Mark Complete', objectName: 'todo_task', icon: 'check-circle', @@ -21,7 +21,7 @@ export const CompleteTaskAction = defineAction({ /** Mark Task as In Progress */ export const StartTaskAction = defineAction({ - name: 'start_task', + name: 'todo_start_task', label: 'Start Task', objectName: 'todo_task', icon: 'play-circle', @@ -49,7 +49,7 @@ export const StartTaskAction = defineAction({ * the handler runs with those values. */ export const DeferTaskAction = defineAction({ - name: 'defer_task', + name: 'todo_defer_task', label: 'Defer Task', objectName: 'todo_task', icon: 'clock', @@ -76,7 +76,7 @@ export const DeferTaskAction = defineAction({ /** Set Reminder — collect input, then run server-side (see DeferTaskAction). */ export const SetReminderAction = defineAction({ - name: 'set_reminder', + name: 'todo_set_reminder', label: 'Set Reminder', objectName: 'todo_task', icon: 'bell', @@ -97,7 +97,7 @@ export const SetReminderAction = defineAction({ /** Clone Task */ export const CloneTaskAction = defineAction({ - name: 'clone_task', + name: 'todo_clone_task', label: 'Clone Task', objectName: 'todo_task', icon: 'copy', @@ -114,7 +114,7 @@ export const CloneTaskAction = defineAction({ /** Mass Complete Tasks */ export const MassCompleteTasksAction = defineAction({ - name: 'mass_complete', + name: 'todo_mass_complete', label: 'Complete Selected', objectName: 'todo_task', icon: 'check-square', @@ -131,7 +131,7 @@ export const MassCompleteTasksAction = defineAction({ /** Delete Completed Tasks */ export const DeleteCompletedAction = defineAction({ - name: 'delete_completed', + name: 'todo_delete_completed', label: 'Delete Completed', objectName: 'todo_task', icon: 'trash', @@ -156,7 +156,7 @@ export const DeleteCompletedAction = defineAction({ /** Export Tasks to CSV */ export const ExportToCsvAction = defineAction({ - name: 'export_csv', + name: 'todo_export_csv', label: 'Export to CSV', objectName: 'todo_task', icon: 'download', diff --git a/examples/app-todo/src/apps/todo.app.ts b/examples/app-todo/src/apps/todo.app.ts index b63e886a0c9..b366938ee49 100644 --- a/examples/app-todo/src/apps/todo.app.ts +++ b/examples/app-todo/src/apps/todo.app.ts @@ -32,7 +32,7 @@ export const TodoApp = App.create({ label: 'Analytics', icon: 'chart-bar', children: [ - { id: 'nav_dashboard', type: 'dashboard', dashboardName: 'task_dashboard', label: 'Dashboard', icon: 'layout-dashboard' }, + { id: 'nav_dashboard', type: 'dashboard', dashboardName: 'todo_task_dashboard', label: 'Dashboard', icon: 'layout-dashboard' }, // The app's report index: a `report` entry with no `reportName`. The // console draws it only because the app declares it. { id: 'nav_reports', type: 'report', icon: 'bar-chart-3' }, diff --git a/examples/app-todo/src/dashboards/task.dashboard.ts b/examples/app-todo/src/dashboards/task.dashboard.ts index c14b7fa937f..d8e9ab00a43 100644 --- a/examples/app-todo/src/dashboards/task.dashboard.ts +++ b/examples/app-todo/src/dashboards/task.dashboard.ts @@ -5,7 +5,7 @@ import type { Dashboard } from '@objectstack/spec/ui'; /** * Task Overview dashboard. * - * ADR-0021 single-form: every widget is bound to the `task_metrics` dataset + * ADR-0021 single-form: every widget is bound to the `todo_task_metrics` dataset * (`dataset` + `dimensions` + `values`, measures/dimensions referenced BY * NAME) so the numbers stay consistent with every other surface. The * dual-form migration window is over — no widget carries the legacy inline @@ -13,7 +13,7 @@ import type { Dashboard } from '@objectstack/spec/ui'; * `runtimeFilter` (presentation scope). */ export const TaskDashboard: Dashboard = { - name: 'task_dashboard', + name: 'todo_task_dashboard', label: 'Task Overview', description: 'Key task metrics and productivity overview', @@ -28,7 +28,7 @@ export const TaskDashboard: Dashboard = { id: 'total_tasks', title: 'Total Tasks', type: 'metric', - dataset: 'task_metrics', + dataset: 'todo_task_metrics', values: ['task_count'], layout: { x: 0, y: 0, w: 3, h: 2 }, }, @@ -37,7 +37,7 @@ export const TaskDashboard: Dashboard = { title: 'Completed Today', type: 'metric', filter: { status: 'completed', completed_date: { $gte: '{today}' } }, - dataset: 'task_metrics', + dataset: 'todo_task_metrics', values: ['task_count'], layout: { x: 3, y: 0, w: 3, h: 2 }, }, @@ -46,7 +46,7 @@ export const TaskDashboard: Dashboard = { title: 'Overdue Tasks', type: 'metric', filter: { due_date: { $lt: '{today}' }, status: { $ne: 'completed' } }, - dataset: 'task_metrics', + dataset: 'todo_task_metrics', values: ['task_count'], layout: { x: 6, y: 0, w: 3, h: 2 }, }, @@ -55,7 +55,7 @@ export const TaskDashboard: Dashboard = { title: 'Completion Rate', type: 'metric', filter: { created_at: { $gte: '{current_week_start}' } }, - dataset: 'task_metrics', + dataset: 'todo_task_metrics', values: ['task_count'], layout: { x: 9, y: 0, w: 3, h: 2 }, }, @@ -72,7 +72,7 @@ export const TaskDashboard: Dashboard = { title: 'Tasks by Status', type: 'pie', filter: { status: { $ne: 'completed' } }, - dataset: 'task_metrics', + dataset: 'todo_task_metrics', dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 2, w: 6, h: 4 }, @@ -82,7 +82,7 @@ export const TaskDashboard: Dashboard = { title: 'Tasks by Priority', type: 'bar', filter: { status: { $ne: 'completed' } }, - dataset: 'task_metrics', + dataset: 'todo_task_metrics', dimensions: ['priority'], values: ['task_count'], layout: { x: 6, y: 2, w: 6, h: 4 }, @@ -94,7 +94,7 @@ export const TaskDashboard: Dashboard = { title: 'Weekly Task Completion', type: 'line', filter: { status: 'completed', completed_date: { $gte: '{4_weeks_ago}' } }, - dataset: 'task_metrics', + dataset: 'todo_task_metrics', dimensions: ['completed_date'], values: ['task_count'], layout: { x: 0, y: 6, w: 8, h: 4 }, @@ -104,7 +104,7 @@ export const TaskDashboard: Dashboard = { title: 'Tasks by Category', type: 'donut', filter: { status: { $ne: 'completed' } }, - dataset: 'task_metrics', + dataset: 'todo_task_metrics', dimensions: ['category'], values: ['task_count'], layout: { x: 8, y: 6, w: 4, h: 4 }, diff --git a/examples/app-todo/src/datasets/task.dataset.ts b/examples/app-todo/src/datasets/task.dataset.ts index 8ca499110a0..7ad6af208ad 100644 --- a/examples/app-todo/src/datasets/task.dataset.ts +++ b/examples/app-todo/src/datasets/task.dataset.ts @@ -16,7 +16,7 @@ import { defineDataset } from '@objectstack/spec/ui'; * query with no joins. */ export const TaskDataset = defineDataset({ - name: 'task_metrics', + name: 'todo_task_metrics', label: 'Task Metrics', description: 'Semantic layer for task counts and time-tracking measures', object: 'todo_task', diff --git a/examples/app-todo/src/flows/task.flow.ts b/examples/app-todo/src/flows/task.flow.ts index bf97d6a9016..34e6b34c508 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -36,7 +36,7 @@ import type { Flow } from '@objectstack/spec/automation'; * `config.organization`. See the file header. */ export const TaskReminderFlow: Flow = { - name: 'task_reminder', + name: 'todo_task_reminder', label: 'Task Reminder Notification', description: 'Automated flow to send reminders for tasks approaching their due date', type: 'schedule', @@ -150,7 +150,7 @@ export const TaskReminderFlow: Flow = { * `config.organization`. See the file header. */ export const OverdueEscalationFlow: Flow = { - name: 'overdue_escalation', + name: 'todo_overdue_escalation', label: 'Overdue Task Escalation', description: 'Escalates tasks that have been overdue for more than 3 days', type: 'schedule', @@ -163,7 +163,7 @@ export const OverdueEscalationFlow: Flow = { ], nodes: [ - // No `objectName` here either: see `task_reminder`'s start node. + // No `objectName` here either: see `todo_task_reminder`'s start node. { id: 'start', type: 'start', label: 'Start (Daily 9 AM)', config: { schedule: '0 9 * * *' } }, { id: 'get_overdue_tasks', type: 'get_record', label: 'Get Severely Overdue Tasks', @@ -175,7 +175,7 @@ export const OverdueEscalationFlow: Flow = { }, }, { - // #19206 — same repair as `task_reminder` above, and this is the flow + // #19206 — same repair as `todo_task_reminder` above, and this is the flow // where the omission was MEASURED: with the per-item steps wired as // ordinary nodes AFTER a body-less `loop`, `currentTask` was never // bound, and the run died at `update_priority` with "refusing to run - @@ -262,7 +262,7 @@ export const OverdueEscalationFlow: Flow = { /** Task Completion Flow */ export const TaskCompletionFlow: Flow = { - name: 'task_completion', + name: 'todo_task_completion', label: 'Task Completion Process', description: 'Flow triggered when a task is marked as complete', type: 'record_change', @@ -441,7 +441,7 @@ export const TaskCompletionFlow: Flow = { /** Quick Add Task Flow — screen flow for quickly adding tasks */ export const QuickAddTaskFlow: Flow = { - name: 'quick_add_task', + name: 'todo_quick_add_task', label: 'Quick Add Task', description: 'Screen flow for quickly creating a new task', type: 'screen', diff --git a/examples/app-todo/src/objects/task.hook.ts b/examples/app-todo/src/objects/task.hook.ts index 34ffb8c48b1..d3f93f1fe4e 100644 --- a/examples/app-todo/src/objects/task.hook.ts +++ b/examples/app-todo/src/objects/task.hook.ts @@ -48,7 +48,7 @@ import { defineHook, type HookContext } from '@objectstack/spec/data'; * `readonly`/strip reasoning applies — only a hook can write the clear. */ const taskHook = defineHook({ - name: 'task_logic', + name: 'todo_task_logic', object: 'todo_task', events: ['beforeInsert', 'beforeUpdate', 'afterUpdate'], handler: async (ctx: HookContext) => { @@ -116,7 +116,7 @@ const taskHook = defineHook({ // touches crosses its due date with no update to observe, so a record hook // is structurally the wrong instrument and any version of this branch would // fire late, or never. The clock-driven sweep already exists in the right - // place: `flows/task.flow.ts`'s `overdue_escalation`, a scheduled flow that + // place: `flows/task.flow.ts`'s `todo_overdue_escalation`, a scheduled flow that // runs daily and selects on `due_date` directly. } } diff --git a/examples/app-todo/src/objects/task.object.ts b/examples/app-todo/src/objects/task.object.ts index 74dabd3f9ad..308ced95936 100644 --- a/examples/app-todo/src/objects/task.object.ts +++ b/examples/app-todo/src/objects/task.object.ts @@ -167,7 +167,7 @@ export const Task = ObjectSchema.create({ // `is_overdue` as formulas was one specific consumer — a FILTER naming a // virtual field matches nothing, because no driver materialises a column // for it. This field has no filter reading it, by construction: its one - // consumer is the `overdue_escalation` flow's `notify` message, which + // consumer is the `todo_overdue_escalation` flow's `notify` message, which // READS the value off a record the `get_record` step already fetched. // `ObjectQL.find` evaluates every formula field on the schema when the // caller asks for no explicit projection (`planFormulaProjection(schema, diff --git a/examples/app-todo/src/reports/task.report.ts b/examples/app-todo/src/reports/task.report.ts index e87b443bb6a..9831ab3744e 100644 --- a/examples/app-todo/src/reports/task.report.ts +++ b/examples/app-todo/src/reports/task.report.ts @@ -2,7 +2,7 @@ import { defineReport } from '@objectstack/spec/ui'; -// ADR-0021 single-form: each report binds the `task_metrics` dataset +// ADR-0021 single-form: each report binds the `todo_task_metrics` dataset // (`dataset` + `rows` + `values`, measures referenced BY NAME). The dual-form // migration window is over — the legacy inline query form is gone, and the // former `overdue_tasks` report now lives as a ListView lens on the task @@ -10,22 +10,22 @@ import { defineReport } from '@objectstack/spec/ui'; /** Tasks by Status Report */ export const TasksByStatusReport = defineReport({ - name: 'tasks_by_status', + name: 'todo_tasks_by_status', label: 'Tasks by Status', description: 'Summary of tasks grouped by status', type: 'summary', - dataset: 'task_metrics', + dataset: 'todo_task_metrics', rows: ['status'], values: ['task_count'], }); /** Tasks by Priority Report */ export const TasksByPriorityReport = defineReport({ - name: 'tasks_by_priority', + name: 'todo_tasks_by_priority', label: 'Tasks by Priority', description: 'Summary of tasks grouped by priority level', type: 'summary', - dataset: 'task_metrics', + dataset: 'todo_task_metrics', rows: ['priority'], values: ['task_count'], runtimeFilter: { status: { $ne: 'completed' } }, @@ -33,11 +33,11 @@ export const TasksByPriorityReport = defineReport({ /** Tasks by Owner Report */ export const TasksByOwnerReport = defineReport({ - name: 'tasks_by_owner', + name: 'todo_tasks_by_owner', label: 'Tasks by Owner', description: 'Task summary by assignee', type: 'summary', - dataset: 'task_metrics', + dataset: 'todo_task_metrics', rows: ['owner'], values: ['est_hours', 'actual_hours'], runtimeFilter: { status: { $ne: 'completed' } }, @@ -50,11 +50,11 @@ export const TasksByOwnerReport = defineReport({ /** Completed Tasks Report */ export const CompletedTasksReport = defineReport({ - name: 'completed_tasks', + name: 'todo_completed_tasks', label: 'Completed Tasks', description: 'All completed tasks with time tracking', type: 'summary', - dataset: 'task_metrics', + dataset: 'todo_task_metrics', rows: ['category'], values: ['est_hours', 'actual_hours'], runtimeFilter: { status: 'completed' }, @@ -62,14 +62,14 @@ export const CompletedTasksReport = defineReport({ /** Time Tracking Report */ export const TimeTrackingReport = defineReport({ - name: 'time_tracking', + name: 'todo_time_tracking', label: 'Time Tracking Report', description: 'Estimated vs actual hours analysis', type: 'matrix', // Matrix: the dataset form flattens rows+across into `rows` for now (cell // values are identical); a dataset-bound `columns`/across dimension is a // follow-up before single-form convergence. - dataset: 'task_metrics', + dataset: 'todo_task_metrics', rows: ['owner', 'category'], values: ['est_hours', 'actual_hours'], runtimeFilter: { status: 'completed' }, diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index d9d2c7b4d60..deb72325276 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -94,16 +94,16 @@ export const jaJP: TranslationData = { // The app's reports, as its report index lists them and each report page is // headed (`reports..label` / `.description`). reports: { - tasks_by_status: { label: 'ステータス別タスク', description: 'ステータス別に集計したタスク' }, - tasks_by_priority: { label: '優先度別タスク', description: '優先度レベル別に集計したタスク' }, - tasks_by_owner: { label: '担当者別タスク', description: '担当者別のタスク集計' }, - completed_tasks: { label: '完了済みタスク', description: '作業時間の記録付きの完了済みタスク' }, - time_tracking: { label: '作業時間レポート', description: '見積時間と実績時間の分析' }, + todo_tasks_by_status: { label: 'ステータス別タスク', description: 'ステータス別に集計したタスク' }, + todo_tasks_by_priority: { label: '優先度別タスク', description: '優先度レベル別に集計したタスク' }, + todo_tasks_by_owner: { label: '担当者別タスク', description: '担当者別のタスク集計' }, + todo_completed_tasks: { label: '完了済みタスク', description: '作業時間の記録付きの完了済みタスク' }, + todo_time_tracking: { label: '作業時間レポート', description: '見積時間と実績時間の分析' }, }, // The quick-add wizard: the flow's name in the runner header and completion // toast, each screen's heading and each field label. flows: { - quick_add_task: { + todo_quick_add_task: { label: 'タスクをすばやく追加', screens: { screen_1: { diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 4653c7d8d8b..56cf79d053c 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -102,16 +102,16 @@ export const zhCN: TranslationData = { // The app's reports, as its report index lists them and each report page is // headed (`reports..label` / `.description`). reports: { - tasks_by_status: { label: '按状态统计任务', description: '按状态分组的任务汇总' }, - tasks_by_priority: { label: '按优先级统计任务', description: '按优先级分组的任务汇总' }, - tasks_by_owner: { label: '按负责人统计任务', description: '按负责人汇总的任务' }, - completed_tasks: { label: '已完成任务', description: '所有已完成任务及其工时记录' }, - time_tracking: { label: '工时跟踪报表', description: '预计工时与实际工时分析' }, + todo_tasks_by_status: { label: '按状态统计任务', description: '按状态分组的任务汇总' }, + todo_tasks_by_priority: { label: '按优先级统计任务', description: '按优先级分组的任务汇总' }, + todo_tasks_by_owner: { label: '按负责人统计任务', description: '按负责人汇总的任务' }, + todo_completed_tasks: { label: '已完成任务', description: '所有已完成任务及其工时记录' }, + todo_time_tracking: { label: '工时跟踪报表', description: '预计工时与实际工时分析' }, }, // The quick-add wizard: the flow's name in the runner header and completion // toast, each screen's heading and each field label. flows: { - quick_add_task: { + todo_quick_add_task: { label: '快速添加任务', screens: { screen_1: { diff --git a/examples/app-todo/src/views/task.view.ts b/examples/app-todo/src/views/task.view.ts index c38e40d5e43..23dbabae4af 100644 --- a/examples/app-todo/src/views/task.view.ts +++ b/examples/app-todo/src/views/task.view.ts @@ -13,7 +13,7 @@ const data = { provider: 'object' as const, object: 'todo_task' }; * tasks — replacing the report entirely. * * Issue #1719: the dashboard's `overdue_tasks_table` / `due_today` widgets - * were count-only `table` widgets on the `task_metrics` dataset — a single + * were count-only `table` widgets on the `todo_task_metrics` dataset — a single * summary row, not the record listing they intended. They are re-modelled * here as the `overdue` / `due_today` views, surfaced via app navigation; * the dashboard keeps the counts on its `metric` widgets. diff --git a/examples/app-todo/test/derived-flag-removal.test.ts b/examples/app-todo/test/derived-flag-removal.test.ts index f912eba3efb..1994b2c568f 100644 --- a/examples/app-todo/test/derived-flag-removal.test.ts +++ b/examples/app-todo/test/derived-flag-removal.test.ts @@ -41,7 +41,7 @@ * failure mode changed — a silent zero became a named 400. * * `status` and `due_date` are stored, indexed columns that already carry the - * information, and both are declared dimensions on the `task_metrics` dataset, + * information, and both are declared dimensions on the `todo_task_metrics` dataset, * so the dashboard/report filters now sit on the semantic layer's own vocabulary. * * ## What these tests pin diff --git a/examples/app-todo/test/loop-body-iteration.test.ts b/examples/app-todo/test/loop-body-iteration.test.ts index 38cf4b32568..1d09eb00c30 100644 --- a/examples/app-todo/test/loop-body-iteration.test.ts +++ b/examples/app-todo/test/loop-body-iteration.test.ts @@ -135,7 +135,7 @@ describe('#19206 — the loop body runs and binds `currentTask`', () => { const { automation, data, emits } = await bootTodoKernel(); const id = await seedOverdueTask(data, 'Renew the domain', 5); - const result: any = await automation.execute('overdue_escalation'); + const result: any = await automation.execute('todo_overdue_escalation'); expect(result.success, result.error).toBe(true); // 1. The loop body's FIRST node ran against the right row. `update_priority` @@ -163,7 +163,7 @@ describe('#19206 — the loop body runs and binds `currentTask`', () => { await seedOverdueTask(data, 'Bravo', 9, 'usr_b'); await seedOverdueTask(data, 'Charlie', 12, 'usr_c'); - const result: any = await automation.execute('overdue_escalation'); + const result: any = await automation.execute('todo_overdue_escalation'); expect(result.success, result.error).toBe(true); // A binding that leaked across iterations (one item reused for all three) @@ -191,7 +191,7 @@ describe('#19206 — the loop body runs and binds `currentTask`', () => { await seedOverdueTask(data, 'Orphan', 6, ''); await seedOverdueTask(data, 'Charlie', 12, 'usr_c'); - const result: any = await automation.execute('overdue_escalation'); + const result: any = await automation.execute('todo_overdue_escalation'); expect(result.success, result.error).toBe(true); // The two notifiable rows were both reached — the failure between them was @@ -206,7 +206,7 @@ describe('#19206 — the loop body runs and binds `currentTask`', () => { due_date: daysAgoIso(-1), owner: 'usr_test_owner', }, SYSTEM); - const result: any = await automation.execute('task_reminder'); + const result: any = await automation.execute('todo_task_reminder'); expect(result.success, result.error).toBe(true); expect(emits).toHaveLength(1); diff --git a/examples/app-todo/test/mcp-actions.e2e.ts b/examples/app-todo/test/mcp-actions.e2e.ts index 4784e677f3f..2e02a5ca9dd 100644 --- a/examples/app-todo/test/mcp-actions.e2e.ts +++ b/examples/app-todo/test/mcp-actions.e2e.ts @@ -118,19 +118,19 @@ function mcpRequest(body: unknown): Request { const actions = JSON.parse(listed.result.content[0].text).actions as any[]; const actionNames = actions.map((a) => a.name); console.log(` → ${actionNames.length} actions: ${actionNames.join(', ')}`); - check(actionNames.includes('complete_task'), 'complete_task (script) is listed'); - check(!actionNames.includes('defer_task'), 'defer_task (modal/UI-only) is NOT listed'); - const complete = actions.find((a) => a.name === 'complete_task'); - check(complete?.requiresRecord === true, 'complete_task is flagged requiresRecord'); - const del = actions.find((a) => a.name === 'delete_completed'); - check(del?.requiresConfirmation === true, 'delete_completed (danger) flagged requiresConfirmation'); + check(actionNames.includes('todo_complete_task'), 'todo_complete_task (script) is listed'); + check(!actionNames.includes('todo_defer_task'), 'todo_defer_task (modal/UI-only) is NOT listed'); + const complete = actions.find((a) => a.name === 'todo_complete_task'); + check(complete?.requiresRecord === true, 'todo_complete_task is flagged requiresRecord'); + const del = actions.find((a) => a.name === 'todo_delete_completed'); + check(del?.requiresConfirmation === true, 'todo_delete_completed (danger) flagged requiresConfirmation'); // ── Step 3 — run_action EXECUTES real business logic ────────────── - console.log('\n⚡ Step 3 — run_action executes complete_task against a real record'); + console.log('\n⚡ Step 3 — run_action executes todo_complete_task against a real record'); const seeded: any = await engine.insert('todo_task', { subject: 'Ship MCP actions', status: 'not_started', priority: 'high' }); const taskId = seeded?.id ?? seeded?.record?.id; check(Boolean(taskId), `seeded a task (${taskId})`); - const ran = await callMcp(bridge, toolsCall(3, 'run_action', { actionName: 'complete_task', recordId: taskId })); + const ran = await callMcp(bridge, toolsCall(3, 'run_action', { actionName: 'todo_complete_task', recordId: taskId })); check(ran.result?.isError !== true, 'run_action returned success (no tool error)'); const payload = ran.result?.isError ? {} : JSON.parse(ran.result.content[0].text); check(payload.ok === true, 'run_action payload reports ok:true'); @@ -145,38 +145,38 @@ function mcpRequest(body: unknown): Request { // ── Step 5 — capability gate (ADR-0066 D4) end-to-end ───────────── console.log('\n🔒 Step 5 — requiredPermissions gate denies, then allows'); - // Same app, but clone_task now declares a capability requirement. + // Same app, but todo_clone_task now declares a capability requirement. const gatedObjects = mergedObjects.map((o) => o.name !== 'todo_task' ? o - : { ...o, actions: o.actions.map((a: any) => (a.name === 'clone_task' ? { ...a, requiredPermissions: ['todo_admin'] } : a)) }, + : { ...o, actions: o.actions.map((a: any) => (a.name === 'todo_clone_task' ? { ...a, requiredPermissions: ['todo_admin'] } : a)) }, ); const denyBridge = bridgeFor({ userId: 'user_2', positions: [], permissions: [], systemPermissions: [] }, gatedObjects); - const denied = await callMcp(denyBridge, toolsCall(5, 'run_action', { actionName: 'clone_task', recordId: taskId })); + const denied = await callMcp(denyBridge, toolsCall(5, 'run_action', { actionName: 'todo_clone_task', recordId: taskId })); check(denied.result?.isError === true, 'run_action denied when the caller lacks the capability'); check(/requires capability/i.test(denied.result?.content?.[0]?.text ?? ''), 'denial cites the missing capability'); // …and list_actions hides what the caller may not run. const denyList = JSON.parse((await callMcp(denyBridge, toolsCall(6, 'list_actions', {}))).result.content[0].text).actions as any[]; - check(!denyList.some((a) => a.name === 'clone_task'), 'list_actions hides the gated action from a non-holder'); + check(!denyList.some((a) => a.name === 'todo_clone_task'), 'list_actions hides the gated action from a non-holder'); const allowBridge = bridgeFor({ userId: 'admin_1', positions: [], permissions: [], systemPermissions: ['todo_admin'] }, gatedObjects); - const allowed = await callMcp(allowBridge, toolsCall(7, 'run_action', { actionName: 'clone_task', recordId: taskId })); + const allowed = await callMcp(allowBridge, toolsCall(7, 'run_action', { actionName: 'todo_clone_task', recordId: taskId })); check(allowed.result?.isError !== true, 'run_action allowed for a holder of the capability'); const allowList = JSON.parse((await callMcp(allowBridge, toolsCall(8, 'list_actions', {}))).result.content[0].text).actions as any[]; - check(allowList.some((a) => a.name === 'clone_task'), 'list_actions reveals the gated action to a holder'); + check(allowList.some((a) => a.name === 'todo_clone_task'), 'list_actions reveals the gated action to a holder'); // ── Step 6 — AI-exposure gate (#2849) end-to-end ─────────────────── console.log('\n🔒 Step 6 — an action without ai.exposed is hidden and uninvokable'); - // Same app, but clone_task no longer opts into the AI surface. + // Same app, but todo_clone_task no longer opts into the AI surface. const unexposedObjects = mergedObjects.map((o) => o.name !== 'todo_task' ? o - : { ...o, actions: o.actions.map((a: any) => (a.name === 'clone_task' ? (({ ai: _ai, ...rest }: any) => rest)(a) : a)) }, + : { ...o, actions: o.actions.map((a: any) => (a.name === 'todo_clone_task' ? (({ ai: _ai, ...rest }: any) => rest)(a) : a)) }, ); const unexposedBridge = bridgeFor({ userId: 'user_3', positions: [], permissions: [], systemPermissions: ['todo_admin'] }, unexposedObjects); const unexposedList = JSON.parse((await callMcp(unexposedBridge, toolsCall(9, 'list_actions', {}))).result.content[0].text).actions as any[]; - check(!unexposedList.some((a) => a.name === 'clone_task'), 'list_actions hides an action the author did not expose to AI'); - const unexposedRun = await callMcp(unexposedBridge, toolsCall(10, 'run_action', { actionName: 'clone_task', recordId: taskId })); + check(!unexposedList.some((a) => a.name === 'todo_clone_task'), 'list_actions hides an action the author did not expose to AI'); + const unexposedRun = await callMcp(unexposedBridge, toolsCall(10, 'run_action', { actionName: 'todo_clone_task', recordId: taskId })); check(unexposedRun.result?.isError === true, 'run_action refuses the unexposed action (fail-closed)'); check(/not exposed to AI/i.test(unexposedRun.result?.content?.[0]?.text ?? ''), 'refusal names the AI-exposure gate'); @@ -190,14 +190,14 @@ function mcpRequest(body: unknown): Request { // JSON-RPC `tools/call`, through both of those layers, into the real gate — // and the record afterwards says whether anything ran. console.log('\n🔒 Step 7 — ai.requiresConfirmation is ENFORCED, and satisfiable'); - // Same app, but complete_task now declares the author's gate. + // Same app, but todo_complete_task now declares the author's gate. const gatedConfirmObjects = mergedObjects.map((o) => o.name !== 'todo_task' ? o : { ...o, actions: o.actions.map((a: any) => - a.name === 'complete_task' ? { ...a, ai: { ...(a.ai ?? {}), requiresConfirmation: true } } : a, + a.name === 'todo_complete_task' ? { ...a, ai: { ...(a.ai ?? {}), requiresConfirmation: true } } : a, ), }, ); @@ -206,7 +206,7 @@ function mcpRequest(body: unknown): Request { // The listing tells a client the gate is there (unchanged behaviour). const gatedList = JSON.parse((await callMcp(confirmBridge, toolsCall(11, 'list_actions', {}))).result.content[0].text).actions as any[]; check( - gatedList.find((a) => a.name === 'complete_task')?.requiresConfirmation === true, + gatedList.find((a) => a.name === 'todo_complete_task')?.requiresConfirmation === true, 'list_actions still reports requiresConfirmation:true (unchanged)', ); @@ -214,7 +214,7 @@ function mcpRequest(body: unknown): Request { const gatedId = gatedTask?.id ?? gatedTask?.record?.id; // 7a — WITHOUT the member: refused, with the declared code, and NOTHING ran. - const refused = await callMcp(confirmBridge, toolsCall(12, 'run_action', { actionName: 'complete_task', recordId: gatedId })); + const refused = await callMcp(confirmBridge, toolsCall(12, 'run_action', { actionName: 'todo_complete_task', recordId: gatedId })); check(refused.result?.isError === true, 'run_action WITHOUT confirm is refused'); let refusedEnvelope: any = {}; try { @@ -224,7 +224,7 @@ function mcpRequest(body: unknown): Request { } check(refusedEnvelope?.error?.code === 'ACTION_CONFIRMATION_REQUIRED', `refusal carries code ACTION_CONFIRMATION_REQUIRED (got ${refusedEnvelope?.error?.code})`); check(refusedEnvelope?.error?.status === 428, `refusal carries status 428 (got ${refusedEnvelope?.error?.status})`); - check(refusedEnvelope?.error?.details?.actionName === 'complete_task', 'refusal names the action'); + check(refusedEnvelope?.error?.details?.actionName === 'todo_complete_task', 'refusal names the action'); check(refusedEnvelope?.error?.details?.confirmationMember === 'confirm', 'refusal names the member to set'); const afterRefusal: any[] = await engine.find('todo_task', { where: { id: gatedId } }); check(afterRefusal?.[0]?.status === 'not_started', `nothing ran — status is still '${afterRefusal?.[0]?.status}'`); @@ -233,16 +233,16 @@ function mcpRequest(body: unknown): Request { // This is the leg that proves the member is not stripped: before the door // grew it, this call was refused exactly like 7a and the action was // permanently un-invokable. - const confirmed = await callMcp(confirmBridge, toolsCall(13, 'run_action', { actionName: 'complete_task', recordId: gatedId, confirm: true })); + const confirmed = await callMcp(confirmBridge, toolsCall(13, 'run_action', { actionName: 'todo_complete_task', recordId: gatedId, confirm: true })); check(confirmed.result?.isError !== true, 'run_action WITH confirm:true succeeds'); const afterConfirm: any[] = await engine.find('todo_task', { where: { id: gatedId } }); check(afterConfirm?.[0]?.status === 'completed', `the handler ran — status is now '${afterConfirm?.[0]?.status}'`); - // 7c — the heuristic must NOT gate: delete_completed is `variant:'danger'` + // 7c — the heuristic must NOT gate: todo_delete_completed is `variant:'danger'` // and the listing calls it requiresConfirmation, but its author declared // nothing, so it stays invokable with no member (this change narrows a // published accept set; refusing here would be the regression). - const undeclared = await callMcp(confirmBridge, toolsCall(14, 'run_action', { actionName: 'delete_completed' })); + const undeclared = await callMcp(confirmBridge, toolsCall(14, 'run_action', { actionName: 'todo_delete_completed' })); check(undeclared.result?.isError !== true, 'a destructive-LOOKING action with no declared flag is NOT gated'); console.log('\n────────────────────────────────────────────────────────────────────────────────'); diff --git a/examples/app-todo/test/overdue-escalation-days-overdue.test.ts b/examples/app-todo/test/overdue-escalation-days-overdue.test.ts index 2657908ed3d..35b0962c96d 100644 --- a/examples/app-todo/test/overdue-escalation-days-overdue.test.ts +++ b/examples/app-todo/test/overdue-escalation-days-overdue.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#18584] The record projection the `overdue_escalation` loop iterates carries + * [#18584] The record projection the `todo_overdue_escalation` loop iterates carries * every field its notification template names — `days_overdue` included. * * The defect this pins was silent: `flows/task.flow.ts` interpolates @@ -82,7 +82,7 @@ function node(id: string): { type?: string; config?: Record } { }; const found = walk(OverdueEscalationFlow.nodes as AnyNode[] | undefined); - if (!found) throw new Error(`#18584 harness: node '${id}' is gone from overdue_escalation`); + if (!found) throw new Error(`#18584 harness: node '${id}' is gone from todo_overdue_escalation`); return found as { type?: string; config?: Record }; } diff --git a/examples/app-todo/test/task-completion-trigger.test.ts b/examples/app-todo/test/task-completion-trigger.test.ts index c7f53052ee9..fdd84aee954 100644 --- a/examples/app-todo/test/task-completion-trigger.test.ts +++ b/examples/app-todo/test/task-completion-trigger.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#6882] `task_completion` is a LIVE record-change flow — binding, gate, and + * [#6882] `todo_task_completion` is a LIVE record-change flow — binding, gate, and * the silence that hid it. * * The defect this pins was not a wrong answer; it was **no answer anywhere**. @@ -109,7 +109,7 @@ function startConfig(flow: unknown): Record { return nodes.find((n) => n?.type === 'start')?.config ?? {}; } -describe('#6882 — app-todo `task_completion` is armed, not dead', () => { +describe('#6882 — app-todo `todo_task_completion` is armed, not dead', () => { it('declares the two keys the engine actually reads', () => { const config = startConfig(TaskCompletionFlow); @@ -140,7 +140,7 @@ describe('#6882 — app-todo `task_completion` is armed, not dead', () => { it('BINDS: the flow is wired to afterUpdate on todo_task at runtime', async () => { const { automation } = await bootTodoKernel(); - const state = automation.getFlowRuntimeStates().find((s: any) => s.name === 'task_completion'); + const state = automation.getFlowRuntimeStates().find((s: any) => s.name === 'todo_task_completion'); expect(state).toBeDefined(); expect(state!.bound, 'the flow resolved a binding and the trigger accepted it').toBe(true); expect(state!.triggerType).toBe('record_change'); @@ -149,7 +149,7 @@ describe('#6882 — app-todo `task_completion` is armed, not dead', () => { // And the silent-miss audit has nothing to say about it, because there is // nothing left unbound. const audit = automation.getTriggerBindingAudit(); - expect(audit.find((a: any) => a.flowName === 'task_completion')).toBeUndefined(); + expect(audit.find((a: any) => a.flowName === 'todo_task_completion')).toBeUndefined(); }); it('REVERSE: drop `triggerType` and the flow vanishes from every audit channel', async () => { @@ -178,14 +178,14 @@ describe('#6882 — app-todo `task_completion` is armed, not dead', () => { // so the assertions above are about the missing key, not about the fixture // failing to register. expect( - automation.getFlowRuntimeStates().find((s: any) => s.name === 'task_completion')!.bound, + automation.getFlowRuntimeStates().find((s: any) => s.name === 'todo_task_completion')!.bound, ).toBe(true); }); it('GATES: only a transition INTO completed fires the flow', async () => { const { automation, data } = await bootTodoKernel(); const ctx = { context: { userId: 'u_todo' } }; - const runCount = async () => (await automation.listRuns('task_completion')).length; + const runCount = async () => (await automation.listRuns('todo_task_completion')).length; // [#7036] No `completed_date` seed. The CREATE-seed workaround this test // used to carry existed only because the completion UPDATE was impossible; @@ -209,7 +209,7 @@ describe('#6882 — app-todo `task_completion` is armed, not dead', () => { // A write that SHOULD fire it. await data.update('todo_task', { status: 'completed' }, { where: { id }, ...ctx }); await sleep(400); - const afterTransition = await automation.listRuns('task_completion'); + const afterTransition = await automation.listRuns('todo_task_completion'); expect(afterTransition.length, 'the completion transition must launch the flow').toBe(1); // It RAN, rather than merely being dispatched: the whole flow reached `end`. expect(afterTransition[0].status).toBe('completed'); @@ -392,8 +392,8 @@ describe('#7036 — completing a task is possible for a normal user', () => { // a hook missing from that array is dead metadata no matter how correct // the hook file is. `task.hook.ts` shipped for exactly that long. const hooks = (TodoApp as { hooks?: Array<{ name?: string; object?: string; events?: string[] }> }).hooks ?? []; - const registered = hooks.find((h) => h.name === 'task_logic'); - expect(registered, '`task_logic` must be in defineStack({ hooks })').toBeDefined(); + const registered = hooks.find((h) => h.name === 'todo_task_logic'); + expect(registered, '`todo_task_logic` must be in defineStack({ hooks })').toBeDefined(); expect(registered!.object).toBe('todo_task'); expect(registered!.events).toContain('beforeUpdate'); }); diff --git a/examples/app-todo/test/task-recurrence.test.ts b/examples/app-todo/test/task-recurrence.test.ts index 382639f0004..79c0a9a2aab 100644 --- a/examples/app-todo/test/task-recurrence.test.ts +++ b/examples/app-todo/test/task-recurrence.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#7037] `task_completion`'s recurrence branch computes a real next due date. + * [#7037] `todo_task_completion`'s recurrence branch computes a real next due date. * * The defect: `create_next_task` wrote * `DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "…")` @@ -177,7 +177,7 @@ async function completeRecurringTask(opts: { await data.update('todo_task', { status: 'completed' }, { where: { id }, ...ctx }); await sleep(600); - const runs = await automation.listRuns('task_completion'); + const runs = await automation.listRuns('todo_task_completion'); const all = await data.find('todo_task', { where: { subject: opts.subject }, ...ctx }); const spawned = (Array.isArray(all) ? all : []).find((r: any) => r.id !== id); return { spawned, run: runs[0], data }; @@ -370,7 +370,7 @@ describe('#7037 — the recurrence branch computes a real next due date', () => await data.update('todo_task', { status: 'completed' }, { where: { id }, ...ctx }); await sleep(600); - const runs = await automation.listRuns('task_completion'); + const runs = await automation.listRuns('todo_task_completion'); expect(runs[0].status).toBe('completed'); // The gate still routes past BOTH nodes — adding the script node did not // put date arithmetic on the non-recurring path. @@ -438,7 +438,7 @@ describe('#7037 — the recurrence branch computes a real next due date', () => // Non-vacuous: the REAL flow, on the very same kernel and record, computes // the date and completes. So the refusal above is the retired dialect, not // a broken fixture or an unusable harness. - const good: any = await automation.execute('task_completion', trigger); + const good: any = await automation.execute('todo_task_completion', trigger); expect(good?.success, `unexpected result: ${JSON.stringify(good)}`).toBe(true); const spawned = (await data.find('todo_task', { where: { subject: 'Reverse fixture task' }, ...ctx })) .find((r: any) => r.id !== id); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 98e98f72a70..03fa02f3678 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -222,7 +222,10 @@ function getViewLabel(view: any, viewPath: string): { label?: unknown; path: str * package's item OF THE SAME NAME, so its name is the base's and is not * judged. A package that declares no namespace has nothing to prefix with and * is not judged: the object rule's posture, which does not invent a prefix on - * the author's behalf either. + * the author's behalf either. A name the PLATFORM requests by its bare + * spelling ({@link PLATFORM_REQUESTED_BARE_NAMES}) is not judged either: a + * package declares it to plug into that door, and the prefixed spelling would + * unplug it. * * ## The duplicate finding, which stays * @@ -277,7 +280,12 @@ function namespacePrefixIssues(stack: any, pathPrefix = ''): LintIssue[] { const name = item?.name; if (typeof name !== 'string' || !name) continue; - if (ns && !isOverrideDeclaration(item) && !carriesPackageNamespace(name, ns)) { + if ( + ns + && !isOverrideDeclaration(item) + && !carriesPackageNamespace(name, ns) + && !isPlatformRequestedBareName(key, name) + ) { const renamed = `${ns}_${name}`; issues.push({ severity: 'warning', @@ -347,6 +355,30 @@ export const NAME_JUDGED_ELSEWHERE: Readonly> = Object.fr docs: 'refused as an error by the doc lint (`docs/namespace-prefix`, ADR-0046)', }); +/** + * Bare names the PLATFORM requests by spelling, per collection: a package + * declares one to plug into the door that asks for it, so it must keep the + * bare spelling, and `naming/namespace-prefix` does not judge it. Exported so + * the phase-3 refusal (#30806) reads the same list. + * + * - `permissions` → `guest_portal`: the public form doors' submit context + * requests the permission set of exactly this name + * (`permissions: ['guest_portal']`, packages/rest/src/form-routes.ts:653), + * and the grant's result masker resolves a set registered under it. + * ADR-0138's ledger keeps that name ("fate: keep") and cites + * `examples/app-crm/src/security/sales-positions.ts#GuestPortalProfile` as + * its reference declaration; `crm_guest_portal` would reach nothing. + */ +export const PLATFORM_REQUESTED_BARE_NAMES: Readonly> = Object.freeze({ + permissions: Object.freeze(['guest_portal']), +}); + +/** Whether `name` in collection `key` is one {@link PLATFORM_REQUESTED_BARE_NAMES} lists. */ +function isPlatformRequestedBareName(key: string, name: string): boolean { + return Object.prototype.hasOwnProperty.call(PLATFORM_REQUESTED_BARE_NAMES, key) + && PLATFORM_REQUESTED_BARE_NAMES[key].includes(name); +} + /** * The types the duplicate finding judges, and the key each occupies at runtime. * diff --git a/packages/cli/test/lint-namespace-prefix.test.ts b/packages/cli/test/lint-namespace-prefix.test.ts index 13a86452c3e..41d93ecd566 100644 --- a/packages/cli/test/lint-namespace-prefix.test.ts +++ b/packages/cli/test/lint-namespace-prefix.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { normalizeStackInput } from '@objectstack/spec'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { ActionSchema } from '@objectstack/spec/ui'; -import { lintConfig, NAME_JUDGED_ELSEWHERE } from '../src/commands/lint'; +import { lintConfig, NAME_JUDGED_ELSEWHERE, PLATFORM_REQUESTED_BARE_NAMES } from '../src/commands/lint'; const RULE = 'naming/namespace-prefix'; const prefixIssues = (config: any) => lintConfig(config).filter((i) => i.rule === RULE); @@ -105,6 +105,21 @@ describe('lint — every package-authored name carries the package namespace (ru } as any).map((i) => i.fix)).toEqual(['crm_billing.refund']); }); + it('a name the platform requests by its bare spelling (`guest_portal`, the form doors\' permission set)', () => { + expect(PLATFORM_REQUESTED_BARE_NAMES).toEqual({ permissions: ['guest_portal'] }); + expect(prefixIssues({ + manifest: { namespace: 'crm' }, + permissions: [{ name: 'guest_portal', objects: {} }], + })).toEqual([]); + // Its twins: the same spelling in ANOTHER collection, and a near-miss in + // the same one, are still bare. + expect(bareIssues({ + manifest: { namespace: 'crm' }, + flows: [{ name: 'guest_portal' }], + permissions: [{ name: 'guest_portal_plus', objects: {} }], + } as any).map((i) => i.fix).sort()).toEqual(['crm_guest_portal', 'crm_guest_portal_plus']); + }); + it('objects — the object prefix is `defineStack`\'s error, not repeated here', () => { expect(prefixIssues({ manifest: { namespace: 'crm' }, diff --git a/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts b/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts index 8e7848d919c..c31113a5fb7 100644 --- a/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts +++ b/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts @@ -39,7 +39,7 @@ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const ACTION = 'showcase_mark_done'; const ACTION_OBJECT = 'showcase_task'; /** A position the showcase declares and nobody needs switched on during this file. */ -const POSITION = 'auditor'; +const POSITION = 'showcase_auditor'; /** A packaged showcase flow that no other flow calls as a subflow, so the §7.3 guard never refuses it. */ const FLOW = 'showcase_scheduled_digest'; diff --git a/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts b/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts index b57ec52fc81..29a02899d50 100644 --- a/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts @@ -31,10 +31,10 @@ const LEDGER = 'sys_metadata_activation'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; /** A showcase position (`src/security/positions.ts`) and the set it names. */ -const POSITION = 'auditor'; +const POSITION = 'showcase_auditor'; const POSITION_SET = 'showcase_auditor'; /** A second showcase position, held by this file's own holder for the resolver leg. */ -const HELD_POSITION = 'exec'; +const HELD_POSITION = 'showcase_exec'; const HELD_POSITION_SET = 'showcase_executive'; const HOLDER_EMAIL = 'exec.holder@example.com'; /** @@ -95,7 +95,7 @@ describe('the catalog activation door, on a booted showcase', () => { it('the resolver over the booted engine stops granting through a switched-off position, and grants again once it is back on', async () => { // Its own holder, so the leg depends on nothing a boot seeds later: a - // signed-up member, assigned the `exec` position by a system write, which + // signed-up member, assigned the `showcase_exec` position by a system write, which // under `single` takes the Default Organization (C8 stage 8b). await stack.signUp(HOLDER_EMAIL); const [user] = await ql.find('sys_user', { where: { email: HOLDER_EMAIL }, limit: 1, context: SYSTEM_CTX }); diff --git a/packages/qa/dogfood/test/delegated-admin-assignment-write-unreadable-row.dogfood.test.ts b/packages/qa/dogfood/test/delegated-admin-assignment-write-unreadable-row.dogfood.test.ts index 19c80d8aa5c..ecde32539f2 100644 --- a/packages/qa/dogfood/test/delegated-admin-assignment-write-unreadable-row.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegated-admin-assignment-write-unreadable-row.dogfood.test.ts @@ -132,15 +132,15 @@ describe("behind the organization wall, a delegated administrator's assignment w const unitB = await unit(orgB, 'Delegated Unreadable B Unit', 'DG-B1'); const ownerB = await userId(OWNER_B); - positionB = await assignment(orgB, ownerB, 'contributor', unitB); + positionB = await assignment(orgB, ownerB, 'showcase_contributor', unitB); // Inside the scope, written by the delegate itself through the door — so // the record-level write rules after the gate (the creator's own row) // admit its edit too, and the edit measures the gate's verdict. const created = await call(tokens.delegate, 'POST', '/data/sys_user_position', - { user_id: ownerA, position: 'contributor', business_unit_id: scopeUnitA }); + { user_id: ownerA, position: 'showcase_contributor', business_unit_id: scopeUnitA }); expect(created.status, `delegate assigns inside its scope — ${JSON.stringify(created.body)}`).toBeLessThan(300); inScopeA = idOf(created.body); - outOfScopeA = await assignment(orgA, delegate, 'contributor', outsideUnitA); + outOfScopeA = await assignment(orgA, delegate, 'showcase_contributor', outsideUnitA); expect(delegate && ownerA && positionB && inScopeA && outOfScopeA, 'every row carries an id').toBeTruthy(); }, 300_000); diff --git a/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts b/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts index cddcbbd7db2..2dc5700a1c7 100644 --- a/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts @@ -19,8 +19,9 @@ // // `@objectstack/example-showcase`'s `onEnable` unconditionally runs // `registerShowcaseApprovalDemo`, which assigns the dev-seeded admin the -// `manager` / `finance` / `legal` / `exec` positions on EVERY boot -// (`ADMIN_APPROVAL_POSITIONS` in `seed-approval-demo.ts`) — i.e. it STAFFS +// `showcase_manager` / `showcase_finance` / `showcase_legal` / `showcase_exec` +// positions on EVERY boot (`ADMIN_APPROVAL_POSITIONS` in +// `seed-approval-demo.ts`) — i.e. it STAFFS // exactly the position `showcase_budget_approval`'s second rung needs // UNSTAFFED for the card's scene. A dogfood pin that re-booted showcase would // either race that seed step or have to disable it, coupling this pin's diff --git a/packages/qa/dogfood/test/install-local-unmanaged-mode.dogfood.test.ts b/packages/qa/dogfood/test/install-local-unmanaged-mode.dogfood.test.ts index 568dd9d446f..7dbc5e6d5a8 100644 --- a/packages/qa/dogfood/test/install-local-unmanaged-mode.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-unmanaged-mode.dogfood.test.ts @@ -77,9 +77,9 @@ async function json(res: Response): Promise { } /** The three items the fixture locks (ADR-0010) — the CRM ships no lock of its own. */ -const LOCKED_DASHBOARD = 'pipeline_dashboard'; +const LOCKED_DASHBOARD = 'crm_pipeline_dashboard'; const LOCKED_APP = 'crm_app'; -const LOCKED_DATASET = 'opportunity_metrics'; +const LOCKED_DATASET = 'crm_opportunity_metrics'; const DASHBOARD_LOCK_REASON = 'The pipeline figures feed the board pack — edit the source package instead.'; const DATASET_LOCK_REASON = 'Forecast reports read these measures; change them in the source package.'; diff --git a/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts b/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts index e1969a96248..0724636b72d 100644 --- a/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts +++ b/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts @@ -9,7 +9,7 @@ // // PUT /meta/flow/crm_convert_lead_wizard 200, an overlay row // PUT /meta/object/crm_lead (a field relabelled) 200, an overlay row -// PUT /meta/position/sales_rep 200, an overlay row +// PUT /meta/position/crm_sales_rep 200, an overlay row // PUT /automation/crm_convert_lead_wizard 200, re-registered // DELETE /meta/flow|object/... over a stored row 200, the row removed // DELETE /meta/object/crm_lead?dropStorage=true 200 — and the managed @@ -51,7 +51,7 @@ const HATCH = 'flow,object,field,permission,position'; const FLOW = 'crm_convert_lead_wizard'; const OBJECT = 'crm_lead'; const PERMISSION = 'crm_sales_user'; -const POSITION = 'sales_rep'; +const POSITION = 'crm_sales_rep'; const VIEW = 'crm_opportunity.all'; type Json = Record; diff --git a/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts b/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts index af994934660..f03121b610b 100644 --- a/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts @@ -27,7 +27,7 @@ * 2. an app-declared name is refused AT BETTER-AUTH'S DOOR, leaving no row — * it fails early and loudly, not at the insert (the #3747 symptom) and not * silently (the pre-#3747 symptom); - * 3. the same intent — "this person arrives as a `contributor`" — succeeds + * 3. the same intent — "this person arrives as a `showcase_contributor`" — succeeds * through invitation placement (ADR-0105 D8), which writes a real * `sys_user_position` row with a `granted_by` stamp. * @@ -53,7 +53,7 @@ const CLOSED_VOCABULARY = ['owner', 'admin', 'delegated_admin', 'member', 'exter /** A `position` the showcase stack declares. Under the retired channel this was * a storable membership role; it must now be reachable only as a position. */ -const APP_POSITION = 'contributor'; +const APP_POSITION = 'showcase_contributor'; /** A `permission` (PermissionSet) the showcase declares — the other route in. */ const APP_PERMISSION_SET = 'showcase_manager'; @@ -127,7 +127,7 @@ describe('ADR-0108: the membership-role vocabulary is closed; capability goes th }, 30_000); it('a stack-declared position is NOT an organization role — refused at the door', async () => { - // The showcase declares `contributor` as a `position`. Under the retired + // The showcase declares `showcase_contributor` as a `position`. Under the retired // channel this returned 200 and stored the name. It must now fail at // better-auth's role check (ROLE_NOT_FOUND), before any insert. const email = 'invitee.contributor.3723@example.com'; diff --git a/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts b/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts index 4e1fb1f45ac..eb229b69700 100644 --- a/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts +++ b/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts @@ -15,7 +15,7 @@ // (`object`) it is 400 VALIDATION_ERROR, and an unknown parameter on a // by-name door is 400 naming it. // - `?package=all` on a list door answers byte-identically to no parameter. -// - A member bound to the `client_liaison` position (the showcase's one FLS +// - A member bound to the `showcase_client_liaison` position (the showcase's one FLS // persona: `showcase_client_liaison` withholds budget, spent and // budget_remaining on showcase_project) reads a definition whose // `sortability.fields` does not list `budget` at all, and the data sort on @@ -59,7 +59,7 @@ describe('[#26572] the v18 /meta read doors, over HTTP on a booted showcase', () // The assignment carries the holder's organization, as a write through a // door does: an unstamped row is UNKNOWN tenancy. await ql.insert('sys_user_position', { - user_id: user.id, position: 'client_liaison', organization_id: membership?.organization_id, + user_id: user.id, position: 'showcase_client_liaison', organization_id: membership?.organization_id, }, SYS); }, 240_000); diff --git a/packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts b/packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts index 40bb3f011e8..5bb86a32b09 100644 --- a/packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts @@ -108,7 +108,7 @@ describe("behind the organization wall, an organization administrator's assignme grantA = await adminGrantOf(ownerA, orgA); grantB = await adminGrantOf(ownerB, orgB); positionB = idOf(await ql.insert('sys_user_position', - { user_id: ownerB, position: 'contributor', organization_id: orgB }, + { user_id: ownerB, position: 'showcase_contributor', organization_id: orgB }, { context: { ...SYS, tenantId: orgB } })); expect(ownerA && grantA && grantB && positionB, 'every row carries an id').toBeTruthy(); }, 300_000); diff --git a/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts b/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts index 073b7f3b5e7..8c021de5a96 100644 --- a/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts @@ -57,8 +57,8 @@ const SYS = { isSystem: true } as const; * admin's READ over org-less rows — is unchanged by their number. */ const SEEDED_RULE_NAMES = [ - 'share_new_inquiries_with_field_ops', - 'share_key_account_qualified_contacts_with_managers', + 'showcase_share_new_inquiries_with_field_ops', + 'showcase_share_key_account_qualified_contacts_with_managers', ]; interface RuleRow { @@ -95,7 +95,7 @@ describe('#7676 — package-seeded (org-less) sharing rules stay visible to an O label: 'Org-bound probe', object: 'showcase_project', recipientType: 'position', - recipientId: 'exec', + recipientId: 'showcase_exec', criteria: { health: 'red' }, accessLevel: 'read', }); @@ -177,7 +177,7 @@ describe('#7676 — package-seeded (org-less) sharing rules stay visible to an O object_name: 'showcase_project', criteria_json: JSON.stringify({ health: 'red' }), recipient_type: 'position', - recipient_id: 'exec', + recipient_id: 'showcase_exec', access_level: 'read', active: true, }, diff --git a/packages/qa/dogfood/test/related-list-server-paging-and-gate.dogfood.test.ts b/packages/qa/dogfood/test/related-list-server-paging-and-gate.dogfood.test.ts index 4fcc306bbf3..1e3c72fc02b 100644 --- a/packages/qa/dogfood/test/related-list-server-paging-and-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/related-list-server-paging-and-gate.dogfood.test.ts @@ -12,9 +12,9 @@ // acceptance[4] — each child the related list serves re-reads by its own // API id with the same row. // acceptance[5]'s server half, negative[2]'s server half — the child read is -// gated at the server on both ends: a member staffed `contributor` (whose +// gated at the server on both ends: a member staffed `showcase_contributor` (whose // set omits showcase_contact read) is refused the identical parent-scoped -// query 403, and a member staffed `manager` reads it 200 with rows. +// query 403, and a member staffed `showcase_manager` reads it 200 with rows. // // The console's request shape (acceptance[0], negative[0]: `top`/`skip` on // every child request), the pager text, the derived tabs and the relatedList @@ -70,8 +70,8 @@ describe('records-forms.related-list-server-pagination — the parent-scoped chi ql = await stack.kernel.getServiceAsync('objectql'); const [northwind] = await stack.rows('showcase_account', { name: 'Northwind' }); northwindId = String(northwind.id); - contributorTok = await staffed('rel-contributor@verify.test', 'contributor'); - managerTok = await staffed('rel-manager@verify.test', 'manager'); + contributorTok = await staffed('rel-contributor@verify.test', 'showcase_contributor'); + managerTok = await staffed('rel-manager@verify.test', 'showcase_manager'); }, 180_000); afterAll(async () => { diff --git a/packages/qa/dogfood/test/rest-config-contract-live.dogfood.test.ts b/packages/qa/dogfood/test/rest-config-contract-live.dogfood.test.ts index 391e771e5fa..3dba5c2982d 100644 --- a/packages/qa/dogfood/test/rest-config-contract-live.dogfood.test.ts +++ b/packages/qa/dogfood/test/rest-config-contract-live.dogfood.test.ts @@ -25,7 +25,7 @@ // something other than the control; // - discovery advertises the default `/data` and `/meta` bases (the crud and // metadata acceptance[3]/[1] defaults); -// - metadata acceptance[3] / negative[1]: a `client_liaison`-bound member's +// - metadata acceptance[3] / negative[1]: a `showcase_client_liaison`-bound member's // served `showcase_project` schema carries no budget, spent or // budget_remaining at all, while the admin's copy carries all three. @@ -76,7 +76,7 @@ describe('[#26572] the default RestServerConfig on a booted showcase: crud, batc const user = await ql.findOne('sys_user', { where: { email: LIAISON_EMAIL }, ...SYS }); const membership = await ql.findOne('sys_member', { where: { user_id: user.id }, ...SYS }); await ql.insert('sys_user_position', { - user_id: user.id, position: 'client_liaison', organization_id: membership?.organization_id, + user_id: user.id, position: 'showcase_client_liaison', organization_id: membership?.organization_id, }, SYS); taskId = (await call(admin, 'GET', '/data/showcase_task?$top=1')).json.records[0].id; expect(taskId).toBeTruthy(); diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index a835088323f..66595207135 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -671,7 +671,7 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { o === 'sys_user' && (verb === 'create' || verb === 'delete') ? 405 : row[verb] ? 'allow' : 'deny'; if (o === 'sys_user_position') { const created = await stack.apiAs(delegateTok, 'POST', `/data/${o}`, { - user_id: targetId, position: 'contributor', business_unit_id: 'bu_west_coast', + user_id: targetId, position: 'showcase_contributor', business_unit_id: 'bu_west_coast', }); const own = String(idOf(await send(o, 'create', created, expectOf('create')))); await send(o, 'read', await stack.apiAs(delegateTok, 'GET', `/data/${o}`), expectOf('read')); diff --git a/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts index 3b0184f2da4..d1bbe0070ef 100644 --- a/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts @@ -29,7 +29,7 @@ import type { ISharingRuleService } from '@objectstack/spec/contracts'; const MEMBER = 'd34-member@verify.test'; /** The showcase's compound (ADR-0058 D3) sharing rule — re-homed by #9237. */ -const RULE = 'share_key_account_qualified_contacts_with_managers'; +const RULE = 'showcase_share_key_account_qualified_contacts_with_managers'; const SYS = { context: { isSystem: true } } as const; // Member set: invoice CRUD + an OWNER read/pre-image policy AND the D4 CHECK diff --git a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts index 2b66d2fbc91..35c4856e396 100644 --- a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts @@ -33,8 +33,8 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { it('declared positions are served by the security catalog (was count = 0)', async () => { const reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack.kernel.getService('metadata') }); const names = (await reader.list('position')).map((e) => e.name); - expect(names, 'showcase declares contributor/manager/exec').toEqual( - expect.arrayContaining(['contributor', 'manager', 'exec']), + expect(names, 'showcase declares showcase_contributor/manager/exec').toEqual( + expect.arrayContaining(['showcase_contributor', 'showcase_manager', 'showcase_exec']), ); }); @@ -44,9 +44,9 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { it('declared criteria sharing rule is served from the registry, CEL→criteria translated — and no row is written for it', async () => { const rules = await servedRules(); - const inquiry = (rules ?? []).find((r: any) => r.name === 'share_new_inquiries_with_field_ops'); + const inquiry = (rules ?? []).find((r: any) => r.name === 'showcase_share_new_inquiries_with_field_ops'); expect(inquiry, 'criteria rule served (was count = 0)').toBeTruthy(); - expect(inquiry?.id, 'a declared rule\'s id is its name').toBe('share_new_inquiries_with_field_ops'); + expect(inquiry?.id, 'a declared rule\'s id is its name').toBe('showcase_share_new_inquiries_with_field_ops'); expect(inquiry?.object_name).toBe('showcase_inquiry'); expect(inquiry?.recipient_type).toBe('unit_and_subordinates'); expect(inquiry?.recipient_id).toBe('bu_field_ops'); @@ -79,12 +79,12 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { // The surviving `position`-recipient demonstration lives on an object under // record-sharing enforcement, so its grant is one the gates consult. const contacts = (rules ?? []).find( - (r: any) => r.name === 'share_key_account_qualified_contacts_with_managers', + (r: any) => r.name === 'showcase_share_key_account_qualified_contacts_with_managers', ); expect(contacts, 'position-recipient rule served').toBeTruthy(); expect(contacts?.object_name).toBe('showcase_contact'); expect(contacts?.recipient_type).toBe('position'); - expect(contacts?.recipient_id).toBe('manager'); + expect(contacts?.recipient_id).toBe('showcase_manager'); expect(contacts?.criteria).toEqual({ $and: [{ stage: 'qualified' }, { company: 'Northwind' }] }); }); }); diff --git a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts index c5ca103a865..402edc7eb29 100644 --- a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts @@ -6,7 +6,7 @@ // // `seed-approval-demo.ts` has provisioned two persona ROWS since #3411/#3409: // Mei Phone (the submitter who holds no approver position) and Ada Auditor (the -// only holder of the `auditor` position, which backs the `finance` group of the +// only holder of the `showcase_auditor` position, which backs the `finance` group of the // per-group 会签 demo). Both were display/routing identities only — enough to // resolve a notify recipient, enough to route an approval, and not enough to // open a second session. Every checklist item that needs TWO acting identities @@ -167,7 +167,7 @@ describe('showcase demo personas are real logins (#9308 fixture 1)', () => { } }); - it('the two sessions are the DISTINCT identities the 会签 demo needs: Ada holds auditor, Mei holds no approver position', async () => { + it('the two sessions are the DISTINCT identities the 会签 demo needs: Ada holds showcase_auditor, Mei holds no approver position', async () => { const positionsOf = async (userId: string) => rowsOf(await ql.find('sys_user_position', { where: { user_id: userId }, context: SYS })) .map((r) => String(r.position)) @@ -176,7 +176,7 @@ describe('showcase demo personas are real logins (#9308 fixture 1)', () => { expect( await positionsOf(AUDITOR_DEMO_USER.id), 'Ada backs the finance group of the per-group demo and nothing else', - ).toEqual(['auditor']); + ).toEqual(['showcase_auditor']); expect( await positionsOf(PHONE_DEMO_USER.id), 'Mei is a clean submitter — never one of her own approvers', @@ -184,6 +184,6 @@ describe('showcase demo personas are real logins (#9308 fixture 1)', () => { expect( await positionsOf(adminId), 'the admin holds the OTHER group, so the two groups resolve to two different people', - ).toContain('manager'); + ).toContain('showcase_manager'); }); }); diff --git a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts index b8f474c5640..72bdf9e7fbb 100644 --- a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts @@ -3,7 +3,7 @@ // [#7626] `$expand` must not become a second, ungated read door. // // The defect this file pins was a CROSS-PERSONA DISCLOSURE on the real -// showcase app: a `contributor`-only member is 403'd on `showcase_contact` +// showcase app: a `showcase_contributor`-only member is 403'd on `showcase_contact` // (neither `showcase_contributor` nor `showcase_member_default` grants it), // yet `GET /data/showcase_invoice?$expand=contact` handed back the referenced // contact FULLY MATERIALISED — byte-identical to what the admin sees — because @@ -62,7 +62,7 @@ describe('showcase: $expand honours the referenced object’s CRUD gate + OWD (# contribTok = await stack.signUp(CONTRIB_EMAIL); ql = await stack.kernel.getServiceAsync('objectql'); - // The persona: `contributor` and nothing else — the app's OWN + // The persona: `showcase_contributor` and nothing else — the app's OWN // `showcase_contributor` set, unmodified. It names `showcase_invoice` and // does NOT name `showcase_contact`; the `showcase_member_default` baseline // every member also holds does not name it either. That absence IS the @@ -75,7 +75,7 @@ describe('showcase: $expand honours the referenced object’s CRUD gate + OWD (# // auditor/delegate personas). const contribId = (await ql.findOne('sys_user', { where: { email: CONTRIB_EMAIL }, context: SYS }))?.id; expect(contribId, 'contributor user provisioned').toBeTruthy(); - await ql.insert('sys_user_position', { user_id: contribId, position: 'contributor' }, { context: SYS }); + await ql.insert('sys_user_position', { user_id: contribId, position: 'showcase_contributor' }, { context: SYS }); const contribSet = await catalogPermissionSet(ql, 'showcase_contributor'); expect(contribSet, 'the app’s contributor set is served by the security catalog').toBeTruthy(); await ql.insert( diff --git a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts index 0e7f1c6ff83..ad5144ddd27 100644 --- a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts @@ -17,7 +17,7 @@ // permission set (ADR-0090): // // • DIRECT — a `sys_user_permission_set` row, and no position; -// • POSITION — a `sys_user_position` row for `contributor`, and no direct set +// • POSITION — a `sys_user_position` row for `showcase_contributor`, and no direct set // row: the set reaches the member through the position binding. // // Every isolation assertion below runs once per holder shape, because a set's @@ -36,7 +36,7 @@ import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const SET = 'showcase_contributor'; -const POSITION = 'contributor'; +const POSITION = 'showcase_contributor'; interface Persona { /** How the persona holds `showcase_contributor`. */ @@ -112,7 +112,7 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut const set = await catalogPermissionSet(ql, SET); expect(set, `the app's ${SET} set is served by the security catalog`).toBeTruthy(); // [ADR-0131 D2/D3] The position → set edge is the app's own declaration — - // `contributor` names `showcase_contributor` in its `permissionSets` — and + // `showcase_contributor` names `showcase_contributor` in its `permissionSets` — and // the resolver reads it there. No `sys_position` row and no junction row // is involved: the boot writes neither. diff --git a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts index 3e0c627ad71..8bb1263eb0a 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -143,16 +143,16 @@ describe('showcase: ADR-0090 permission-model zoo', () => { // ── ADR-0090 D12: the delegated-admin gate, positive and negative ──────── it('a delegate assigns an allowlisted position inside their subtree', async () => { - // [ADR-0131 D3/D4] `contributor` binds `showcase_contributor` on its own + // [ADR-0131 D3/D4] `showcase_contributor` binds the set of the same name on its own // definition (`permissionSets`), which the allowlist judges; no binding row. const assign = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_position', { user_id: targetId, - position: 'contributor', + position: 'showcase_contributor', business_unit_id: 'bu_west_coast', }); expect(assign.status, 'in-subtree, allowlisted assignment passes').toBeLessThan(300); const row = await ql.findOne('sys_user_position', { - where: { user_id: targetId, position: 'contributor' }, + where: { user_id: targetId, position: 'showcase_contributor' }, context: SYS, }); expect(row, 'assignment persisted').toBeTruthy(); @@ -177,7 +177,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('the same delegate is refused OUTSIDE their subtree (no lateral reach), on the scope-violation code', async () => { const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_position', { user_id: targetId, - position: 'contributor', + position: 'showcase_contributor', business_unit_id: 'bu_hq_finance', }); const a = await refusalEnvelope(r); @@ -202,7 +202,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { // An unanchored assignment: no `business_unit_id` to judge against any scope. const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_position', { user_id: targetId, - position: 'contributor', + position: 'showcase_contributor', }); const a = await refusalEnvelope(r); expect([a.status, a.code], a.message).toEqual([403, 'PERMISSION_DENIED']);