Skip to content

test: run the hook, flow and action suites on @objectstack/verify and retire the five hand-built harnesses - #2013

Merged
os-zhuang merged 10 commits into
mainfrom
claude/issue-1595-verify-handle
Oct 8, 2026
Merged

os-zhuang merged 10 commits into
mainfrom
claude/issue-1595-verify-handle

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #1595
Clause-②: no

The test suites stop running on hand-built stand-ins. Every hook, flow and action suite now boots the shipped app (objectstack.config, both packages) through @objectstack/verify 17.7.0 and drives it through the platform's own doors. The five stand-ins under test/helpers/ are deleted, along with the seven suites that only proved them. Each ported test pins the same business fact as before. Where a real write cannot reproduce the old setup, the test now pins what the engine actually does, and the change is listed below.

Before: a hook test called hook.handler(makeCtx(...)) with a ctx.api backed by plain arrays. A flow test ran AutomationEngine over an array "driver" with a stub messaging service. An action test ran QuickJS over a stub ObjectQL.
After: a person writes through hooks.run, flows run through flows.run / flows.resume, actions run through actions.run. Prior states are set with seed or a system update. Results are read with rows. Async hooks are waited for.

Mechanism probe (assumption 1)

Probe run on branch base 99d290ad under the verify lock (VERDICT command-exit 0). bootStack(objectstack.config, { automation: true }) booted in 3.35 s. It registered 32 flows, 42 hooks and 22 actions from both packages (app.objectstack.hotcrm.service, app.objectstack.hotcrm).

  • A sales hook ran on hooks.run insert: account_protection stamped name_normalized / territory / billing_country, and opportunity_lifecycle stamped probability 10.
  • A service hook ran: case_sla_defaults stamped priority_rank and sla_due_date.
  • A system flow ran: case_escalation_stamp → {success:true}.
  • A record flow ran: campaign_lead_member_enroll wrote 1 member.
  • A screen flow paused correctly: schedule_followup → paused.
  • Tenancy reported {posture:'single', isolationActive:false}.

The lean boot was missing five capabilities that this app's requires[] resolve to under objectstack serve: triggers, approvals, messaging, audit and email. These are mounted through extraPlugins in test/helpers/verify-stack.ts (gap 1 below).

Per-helper port

helper (base lines) importers at base handle API now commit
metadata-fixtures.ts (172) 18 metadata.objects() / metadata.object() on bootStackOnce; authored collections moved to composed-stack.ts e0a85770
tenancy-probe.ts (86) 6 bootStack (real AuthPlugin tenancy), signUpPerson → signUp + system RBAC rows, hooks.run, rows, contextFor 69a26060
action-sandbox.ts (427) 19 actions.run (dispatcher → QuickJS body → real engine), hooks.run, rows; body text via @objectstack/cli/hook-body 49b229a3
flow-harness.ts (720) 26 flows.run / flows.resume, hooks.run / seed / rows, POST/DELETE /automation for flow variants, sys_automation_run run history c593a86b
hook-harness.ts (641) 41 hooks.run / seed / rows, the form door's context for guests, recordEngineWrites for writes a hook handed over 13d8d742

Follow-ups on the branch: 1f952f98 (AGENTS.md and the developer guide: citations repointed off the deleted suites), 330517ff (changeset), 632787ab (runtime-coverage reads names: the ported price-fill hook, the contract hand-off flow and the two enrolment subflows are named again where they run). Base 99d290ad.

Deleted self-proofs: flow-harness-declared-columns, harness-lookup-shape, hook-input-shape, hook-write-shape, hook-query-predicate, action-sandbox. Also deleted, not on the card's list: sharing-posture-declaration. It asserted that hand-built kernels mount tenancyProbe before SharingServicePlugin. After the port no suite mounts that probe, so it checks nothing (measured: 0 mounters, its own sentinel red). The business facts action-sandbox.test.ts carried moved to the new test/script-bodies.test.ts (29 cases).

The five exemplars, asserted fact before → after

exemplar before (stand-in) after (real engine)
hooks-runtime-sales each handler on makeCtx, store rows read back the person the case is about writes through hooks.run (rep, manager for contracts/acceptance, admin for deletes); stored row or refusal. Same 89 facts. Changes: an unknown stage is refused by the engine (VALIDATION_FAILED) before the hook runs; the quote freeze names the quote by its display title; rep quote acceptance cannot draft a contract (finding F1), so acceptance runs as a manager; "all opps closed" account deletes are refused by the engine's RI rather than allowed (pinned as engine-not-guard); and "drafts/expired/terminated cascade with the account" is refused on the contacts (F8)
flow-quote quote_generation over an array store, 70 % discount flows.run + resume as the rep on a system-seeded deal. #1206's property (both fields inexact before the fix) is re-measured at 34 %, because the 60 % ceiling on crm_quote refuses 70 %
global-actions QuickJS over the stub engine actions.run on all activity targets, events, attendees, record_label, actor_name read from stored rows. Two changes: a single picked contact is refused as a bare value by the dispatcher (a multiple lookup wants a list), and an unparseable start date is refused by the dispatcher before the body can write NaN
sla-at-risk-live-work case_sla_defaults run on a synthetic ctx, columns inserted into a one-object ObjectQL (memory + sqlite) a service agent inserts each case through hooks.run on the shipped app, on the sparse and the SQL datasource, and the shipped view filter runs through rows. Identical: a resolved case stores is_closed false, the old spelling returns [live-critical, resolved-high], and the shipped filter returns exactly [live-critical]
unassigned-case-triage-reach hand-assembled ObjectKernel + tenancyProbe('single') + SharingServicePlugin on memory and sqlite bootStack on both drivers, the agent signed up, writes as hooks.run, reads as rows({as}), shares read from sys_record_share. Same assertions. Admin standing is now read from the resolved context's posture (MEMBER) instead of hasPlatformAdminGrant, and reach is measured over each fixture's own rows (the boot replays seed cases)

Tests whose expectation changed (before → after; the business fact is unchanged unless the row says otherwise)

Every change is also explained in a comment beside the test.

Flows (c593a86b)

  • flow-quote: discount 70 % → 34 %, because the ceiling refuses 70 %. Deals are system-seeded because a rep-created deal of 100k or more is approval-locked (F2).
  • flow-conversion: the converter is now a sales manager instead of a rep, because a rep's conversion into a new account is refused (F3). Lead status working → contacted, because working is not an option.
  • flow-billing-handoff: deliveries are read from the real outbox sys_http_delivery (receiver stubbed 503). "Bulk update" is now a real predicate update: 17.7.0 binds each row's pre-image, so a predicate update into closed_won hands off once and a bulk edit of a won deal hands off zero. The reverse check registers a flow clone through POST /automation. Status active is now refused at the save. close_date is the stored one.
  • flow-campaign-enrollment: contacts and leads with no email cannot exist (email is required), so those fixtures are pinned as the refusal.
  • flow-case-actions: claim — the flow's own payload carries no owner_id (the recorder shows it), and the stored owner is the caller. This replaces "owner still null" from the store that had no hooks.
  • flow-escalation-ownerless-case: the verdict is read from sys_automation_run. The blank-owner × on_create shapes cannot be written by a person, so they are pinned as the refusal. A record that vanished now fails at assign_senior_agent instead of succeeding. 31 → 27 cases.
  • flow-record-change: start conditions are exercised with real writes. Shapes that no write can produce run through runRecordFlow. The Record-change flows never got the runAs: 'system' treatment the scheduled ones did — every system-driven write refuses their data ops (12 failed runs on one boot) #684 approval run now pauses with a real sys_approval_request. Flows with runAs dropped are refused at POST /automation (422 flow-update-readonly-field). lead_assignment is split in two: a web-to-lead submission gets its SLA and is stored ownerless, and an integration-written owned lead (seed plus a user-less run) gets its SLA and one alert. The second half is the base fact.
  • flow-run-summary: sweeps are read as deltas over a settled database. acted counts the writes; notifications are unmeasured.
  • flow-scheduled: case status working → in_progress; the breached case is high (a critical case is escalated on create). "Does not re-process" is now asserted on an unchanged updated_at. The renewal window is pinned at both edges by behaviour instead of by the recorded query. forecast_snapshot 与启动重播种的互动:每次 dev 重启后,当季出现一条无 owner 的幻影快照行与 owner 键控行并存 #702 runs a real cold boot and a warm replay.
  • flow-sla-ownerless-case / -assignment: each runs per datasource with the shapes a write can produce. Counts per run are 5 → 4.
  • forecast-snapshot-amounts: a null amount is refused by the engine (required), so that case is pinned as the reason.
  • knowledge-deflection: the blank article is now asserted as a null stored column.
  • account-name-normalized-match: whitespace-only names are refused (they used to fold to null). An unwrapped LOWER({x}) lands verbatim as text, so its comment is corrected. "A lead with no match key stops the conversion" is now pinned as a measured defect (F7).
  • opportunity-*-approval-gate: "INPUT-FIRST" runs end to end through real approvals. A rep's write carrying the readonly qualification verdict is stripped and refused with RECORD_LOCKED (the expectation flipped).

Hooks (13d8d742)

  • guestInsert now uses the anonymous form door's execution context: publicFormGrant, guest_portal, anonymous (@objectstack/rest registerFormEndpoints). Before, it used an empty context that no door produces.
    • guest-submission-sanitisation: the planted owner is stripped, and the case lands unowned, not with the agent (the grant cannot read the agent pool).
    • flow-record-change web lead: stored ownerless.
  • priority-rank-parity: "unknown priority falls back to 0" cannot be reached, because priority is a required select that the engine refuses on every writer. It is pinned as the refusal on both objects.
  • ownership-model round-robin: ownerless intake is now a system seed. Measured: under an empty context the count reads 0 (first holder wins), and under the form context the pool read is denied.
  • forecast-period-end-boundary: "gate can fail" boots the shipped artifact with the rule removed. The legacy row is a real upgrade: the app without the rule writes to a databaseFile, then the shipped app cold-boots over it. "Refuses an UNRELATED edit to a row stored wrong": a {quota}-only edit is now admitted and re-derives the window (the hook runs on every update). The refusal is pinned on the form's payload, which carries the stale period_end.
  • contact-email-tenant-scope: runs on an org-bound boot (orgContext: true) plus the untenanted one. "Reads org off the session" is re-aimed at the resolved tenantId. "Skips the guard when org cannot be resolved" is measured on the untenanted boot: the index refuses (DUPLICATE_RECORD), not the hook.
  • converted-lead-guard: valid values are used (email, rating ≤ 5). A label of company alone is unreachable because names are required.
  • case-first-response: the event payload cannot carry first_response_date (unknown field), so that case is re-aimed. A denied read is now an agent who cannot see the case. "No api" is re-aimed at an imported held event.
  • line-item-conventions: no product or no catalog price is refused (both are required). "No api" is re-aimed at a system-imported line.
  • knowledge-feedback: readers vote through the actions (their own insert is refused by controlled_by_parent). An admin-withdrawn vote stays counted (F12, pinned).
  • campaign-member-lifecycle: the CONTACT round trip runs as the admin. A marketing user's contact unsubscribe never reaches the contact (F13, pinned).
  • demo-staffing: the table's people are signed up, the territory rules are re-evaluated through POST /sharing/rules/:id/evaluate, and accounts are read as each rep (6 / 2 / 1, as before). The escalation hand-off works on a system escalation. An agent's own escalation keeps the case (F14, pinned).
  • case-assignment: pools are real position holders, released after each case. "Reads the pool literal" moved to the shipped body text, because reads are not observable. The web-to-case submission is never round-robined (F9, pinned). "Reverse verification: swap the order" is moot once the forward assignment cannot run for a guest; it is replaced by the stripped-owner pin.
  • case-sla-matrix: unknown tier and unknown priority are refused by the select. The critical / no-api case is re-aimed at the anonymous submitter.
  • freeze-guard-reference-cleanup: "a null over an already-empty link" is refused on the sparse datasource (same fact). On SQL it is no change and lands; this new case pins the divergence.
  • hooks-runtime: the price fill keeps a negotiated price. Closed deals and accepted quotes are skipped for a system line. Clearing the last line leaves the amount at the last rollup. A person's supplied stage_entry_date lands NULL (F10, not pinned; reported).
  • hooks-runtime-service (107):
    • A guest case gets the field default low.
    • working → in_progress.
    • resolution_time_hours is measured from a case opened 24 h earlier (the hook stamps the close).
    • The account bump on resolve is shown as the admin. An agent's resolve does not bump (F15a, pinned).
    • Contract shrink/extend use terms that the term rule accepts.
    • A dateless campaign cannot be stored (both dates are required).
    • Attribution runs as the admin, with re-attribution on an open deal. A rep's win does not reach the campaign (F15b, pinned).
    • Conversion: the five facts are shown on the sparse datasource. On SQL no membership is ever promoted (F16, pinned). A rep's conversion does not promote (F15c, pinned).
    • Forecast: a custom end, an unparseable start and a mid-period start are refused by the schema.
    • Unknown recurrence type is refused.
    • Lead lock message: Ada Lovelace - Acme.

Metadata / tenancy / actions (e0a85770, 69a26060, 49b229a3)

Static it() call sites across all test files: base 2462 → head 2375. The seven deleted self-proofs held 131. The 172 test files run 3563 tests.

Platform gaps: one local path each in test/helpers/verify-stack.ts

None of these re-implements engine behaviour. Each is the engine's own service on the verify-booted kernel.

  1. Capability → plugin mapping: CAPABILITY_PROVIDERS is not exported, so the five plugins requires[] resolve to are named in extraPlugins.
  2. systemUpdate: there is no system-context UPDATE door. seed only inserts, and hooks.run always runs as a person.
  3. predicateUpdate: there is no door onto the engine's multi: true predicate update. REST updateMany iterates by id.
  4. guestInsert: the anonymous form door POST /forms/:slug/submit is not served by the handle's dispatcher (measured ENDPOINT_NOT_FOUND). The door's execution context is reproduced instead.
  5. runRecordFlow: there is no door for a user-less record trigger or for a record the engine no longer holds. Every handle write fires as a person, and seed fires no flows.
  6. recordEngineWrites: there is no way to observe what a hook handed the engine, when an async hook finished, or a staged refusal.
  7. runShippedHook: the handle boots the source config, so there is no door onto the lowered-body path (the production refusal envelope).
  8. conditionHolds: automation.evaluateCondition is a kernel service. It is used for truth tables over row shapes that no write produces.
  9. (not a gap) sharingRules.evaluateRule is called as a kernel service in two suites, but the REST door POST /sharing/rules/:id/evaluate works on the handle (demo-staffing uses it). This is a cleanup follow-up, not a platform gap.

Count: 8 local paths (1–8).

Helper fates

  • Deleted (5): hook-harness, flow-harness, action-sandbox, metadata-fixtures, tenancy-probe.

  • New (1): verify-stack.ts, which holds the shared boot options and the eight gap paths.

  • Kept, as the card expects (4): repo-root, heading-label, docs-anchors, persona-vocabulary.

  • Kept, deviation from "only the four" (6):

    • composed-stack (108 importers): the static composed stack. It gained the authored collections from metadata-fixtures, and it only reads authored metadata.
    • src-roster, config-globs, flow-regions, registration-lists: static source/metadata readers.
    • identity-objects (4 importers): still used by suites that hand-build an ObjectKernel and are not among the five stand-ins' importers (e.g. the freeze-guard-reference-cleanup cascade block).

    None of these executes app behaviour.

Dependency diff

package.json gains, at the exact pin: @objectstack/verify 17.7.0, @objectstack/plugin-audit 17.7.0, @objectstack/plugin-email 17.7.0 (devDependencies). Every @objectstack/* a test imports is declared (21 packages; git grep -h "from '@objectstack/" test vs package.json). The card's aim of "verify the only test-side entry besides spec" is not met: tests still import objectql, runtime, cli/hook-body, the drivers and others directly, all declared.

Measured defects the real engine surfaced (pinned with "⚠️ measured defect" where the base asserted the opposite; full evidence in the report)

  • F1 rep quote acceptance drafts no contract
  • F2 quote_generation leaves a quote behind on an approval-locked deal
  • F3 rep conversion into a new account refused
  • F4 seed replay refuses cel-date rows under the verify boot
  • F6 get_record projection widened: platform columns sent to the billing endpoint
  • F7 a key-less lead converts onto an unrelated key-less account
  • F8 an account with any contract cannot be deleted (cascade order)
  • F17 (F8 family, new this round) a customer account whose deals are all closed still cannot be deleted. The guard's own refusal for an open deal says "Close or reassign it first", but closing does not unblock the delete: crm_opportunity.crm_account is required and does not cascade, so the engine refuses it (DELETE_RESTRICTED, crm_opportunity)
  • F18 (F8 family, new this round) a contact whose only contract is expired or terminated still cannot be deleted. The guard's refusal says "Close or reassign those records first", but closing does not unblock the delete either (DELETE_RESTRICTED, crm_contract)
  • F9 web-to-lead/case submissions are never round-robined or deduplicated under the form grant
  • F10 a person's insert carrying readonly stage_entry_date lands it NULL
  • F11 the Settlement-Only gate is absent for a caller who cannot read the account
  • F12 / F13 / F15a–c hooks writing cross-object data as a caller without the right (feedback counters; contact opt-out; account recency on resolve; campaign attribution; conversion promotion)
  • F14 an agent's escalation is never handed to the manager pool (the pool read is denied)
  • F16 on SQL the conversion refresh never promotes a membership (projection drops id)

Retitle round (70084d9c): titles that kept the base wording while the asserted outcome flipped

A test's title must say what its body asserts. These cases kept their base 99d290ad title, but on the real engine the asserted outcome flipped (allowed → refused, written → refused, omitted → written). Each one is retitled. Defects use the ⚠️ … (measured defect) form, with a comment and a "the defect is fixed: rewrite this case" message. The rest are reworded to what the body asserts. No matcher changed. The contact case is split in two so that each half has a true title, and the whitespace-only fold row moves to its own it.each table.

# old title new title file:line
1 allows deleting a customer account whose opportunities are all closed ⚠️ a customer account whose deals are all closed still cannot be deleted (measured defect) test/hooks-runtime-sales.test.ts:623
2 lets drafts, expired and terminated contracts cascade with the account ⚠️ an account whose contracts are all draft, expired or terminated still cannot be deleted (measured defect) test/hooks-runtime-sales.test.ts:679
3 allows deleting a contact whose references are all settled (first half) allows deleting a contact whose deals and quotes are all settled test/hooks-runtime-sales.test.ts:900
4 allows deleting a contact whose references are all settled (second half, split out) ⚠️ a contact whose only contract is expired still cannot be deleted (measured defect) test/hooks-runtime-sales.test.ts:920
5 only protects customer accounts, not prospects the guard only protects customer accounts — a prospect’s deal is the engine’s refusal, and a bare prospect goes test/hooks-runtime-sales.test.ts:632
6 leaves an unknown stage entirely alone rather than guessing refuses an unknown stage before the derivation could guess at it test/hooks-runtime-sales.test.ts:211
7 is a no-op when no product is chosen or the product has no price refuses a line with no product, and a product with no price, before the fill runs test/hooks-runtime-sales.test.ts:475
8 refers to an unnamed contact rather than keying it (#1243) cannot store an unnamed contact, so the refusal names the contact and never keys it (#1243) test/hooks-runtime-sales.test.ts:884
9 folds %s (row "a whitespace-only value", account_protection) refuses %s before any fold — the name is required test/account-name-normalized-match.test.ts:359
10 folds %s (row "a whitespace-only value", lead_duplicate_check) refuses %s before any fold — the company is required test/account-name-normalized-match.test.ts:403
11 drops the separator rather than dangling it when a half is missing drops the separator when the case number is missing, and a blank subject is refused before the hook test/escalation-task-subject.test.ts:127
12 never enrols an opted-out, converted, email-less or off-status lead never enrols an opted-out, converted or off-status lead, and an email-less one cannot be stored test/flow-campaign-enrollment.test.ts:163
13 never enrols an opted-out, email-less or off-segment contact never enrols an opted-out or off-segment contact, and an email-less one cannot be stored test/flow-campaign-enrollment.test.ts:220
14 sums a null amount as 0 instead of failing the owner's sweep refuses a null amount at the write, so the owner's sweep never sums one test/forecast-snapshot-amounts.test.ts:77
15 reads the verdict INPUT-FIRST, as the step-14 gate does judges a rep’s write carrying the readonly verdict by the stored one and refuses it; the approval’s own stamp lets the act through test/opportunity-qualification-approval-gate.test.ts:293
16 omits the absent lookup and still wins the deal when the contract refuses still wins the deal when the contract refuses, every lookup it wrote a record id (its doc comment corrected to match) test/quote-accepted-lookups.test.ts:253
17 drops the half the lead does not carry rather than dangling a separator refuses a lead without first_name, last_name or company, so the title never has a half to drop test/record-id-not-in-prose.test.ts:109
18 says what it can when the opportunity pre-image carried no name refuses an opportunity without a name, so the activation title always has one test/record-id-not-in-prose.test.ts:157
19 drops the separator rather than dangling it when a half is missing drops the separator when the quote number is missing, and a quote without a name is refused test/record-id-not-in-prose.test.ts:223
20 refers to an unnamed duplicate rather than keying it refuses a contact without a name, so a duplicate is never unnamed test/record-id-not-in-prose.test.ts:290

How the sweep was run:

  • Every it / test call was parsed with the TypeScript compiler API. Base: all 178 test files at 99d290ad, including the deleted suites. Head: every test file.
  • Of 2437 head cases, 2365 keep a base title verbatim. 1723 of those have a byte-identical body, and 642 have a changed body.
  • Every one of the 642 was screened, and every pair that screened as a possible flip was read side by side:
    • 68 flagged by polarity signals: a refusal captured where the base expected success (or the reverse), a newly asserted error code, a shift between positive and negative matchers, or new "refused / unreachable / measured" wording in the body.
    • 246 more whose multiset of matchers and literal arguments changed.
    • 328 had identical matcher and literal-argument multisets, so only their setup changed.
  • The 20 rows above are all the flips found. Rows 1, 2 and 4 are defects.

Checked and left as is (the title is still true; not a flip):

  • contact-email-tenant-scope.test.ts:160: the guard is still skipped. The unique index refuses the duplicate, and the test tells it apart by DUPLICATE_RECORD vs the guard's DUPLICATE_VALUE.
  • flow-billing-handoff.test.ts:336: active "does not exist" and is now refused at the save.
  • The three "…and never gets that far once runAs is dropped" cases: the flow is now refused at POST /automation instead of at run.
  • flow-scheduled.test.ts:738 "coerces a string amount": SQLite returns the string as a number. The comment says so, and a sum is still asserted.
  • global-actions.test.ts:483 / script-bodies.test.ts:437 "reads sys_user once — and only because the dispatcher delivered no name": the delivered-name half is no longer asserted, because no door delivers a name on 17.7.0. A half was dropped, but nothing flipped. Reported, not retitled.

Gate on the new head: pnpm verify ran on 70084d9c (git rev-parse --short HEAD printed by the run itself), under the shared verify lock → os-verify-lock: VERDICT command-exit 0 (held 8m44s). Every stage reported the same as on 632787ab: validate ✓, typecheck clean, lint 0 errors / 0 warnings / 18 suggestions, i18n gate ✓, hygiene ✓, tokens ✓, build ✓ (the same 8 author-time warnings), Test Files 172 passed (172) · Tests 3564 passed | 1 skipped (3565). The one extra test is the split contact case. --coverage was not re-run, because no matcher changed.

#2000 coupling

claude/issue-2000-picklist-metadata @ c8f331cf (not touched) edits 7 files this PR also edits: global-actions, hook-org-inheritance, i18n-references, opportunity-line-item-add-picker, placeholder-picklist-options, quote-accepted-payment-terms, view-references. #2000's versions of all seven still import a deleted stand-in (metadata-fixtures / action-sandbox / hook-harness). Whichever lands second must rebase: if this one lands first, #2000 has to move its additions onto the verify handle. #2000 also changes src/ picklists that several ported suites read at runtime.

Gates

Both gates ran on the final commit 632787ab (git rev-parse --short HEAD printed by the run itself), each under the shared verify lock.

  • pnpm verify → os-verify-lock: VERDICT command-exit 0 (held 8m13s):

    • validate: ✓ Validation passed
    • typecheck: clean (it covers the 173 *.test.ts files, checked with --listFiles)
    • lint (objectstack lint --strict): 0 errors, 0 warnings, 18 suggestions
    • lint:i18n-gate: ✓ i18n lint gate: 0 i18n/missing-* issues
    • hygiene: ✓ source hygiene clean
    • hygiene:tokens: ✓ source token ratchet clean
    • build: ✓ Build complete, "all 42 callables are body-only", with 8 author-time warnings, all approval-approvers-may-resolve-empty on approval flows this PR does not touch
    • test: Test Files 172 passed (172) · Tests 3563 passed | 1 skipped (3564)
  • pnpm test --coverage → VERDICT command-exit 0, same counts. Coverage scope is src/*/objects/*.hook.ts. The thresholds in vitest.config.ts are unchanged (95 / 92 / 92 / 78) and met:

    metric base 99d290ad head 632787ab
    Statements 95.5 % 96.11 %
    Branches 85.45 % 86.7 %
    Functions 96 % 97 %
    Lines 99.4 % 99.5 %
  • An earlier pnpm verify on 330517ff was red in exactly one file, runtime-coverage (2 cases): a ported hook and three flows were no longer named in a runtime file. Commit 632787ab fixes it, and both gates above ran after it.

Acceptance notes

  • Observation, not filed: with a sparse datasource, the freeze guards treat a key absent from the stored row as an attempted edit when a write echoes it as null. On SQL the same echo is no change. Measured per guard; reach through the record form is unmeasured.
  • Observation, not filed: adding a user to service_agent does not re-grant the triage rule on existing unowned cases until a reconcile.
  • Stale src comments (⛔ not edited here; carrier [finding] 26 source comments in src/ cite src/flows/ — a directory ADR-0130 removed (blocked on Track A: file surface collides) #1919) still cite deleted suites: _hook-api.ts:55, account.object.ts:271, lead.hook.ts:199, case.hook.ts:75 (hook-query-predicate / hook-input-shape); campaign.hook.ts:35, _line-item-price-fill.ts:31, activity-actions.ts:118,122, _hook-api.ts:158, _territory.ts:35, account.hook.ts:106,170, lead.campaign-metrics.hook.ts:24, opportunity.campaign-metrics.hook.ts:26 (action-sandbox). objectstack.composition.ts:339-340 too (shipped config file, left alone). billing-handoff-closed-won.flow.ts:70 / contract-activated.flow.ts:70 / opportunity-won-alert.flow.ts:70 say that a bulk updateMany binds previous as null; 17.7.0 binds the per-row pre-image.
  • AGENTS.md changed in two citations only (1f952f98). Maintainer to confirm.
  • The filter-key source scan in the deleted hook-query-predicate is covered by the HookQuery type, which has no filter member (an excess property is a compile error under pnpm typecheck).
  • Commit 632787ab is a follow-up fix for runtime-coverage, which went red after the flow and hook ports because three flows and one hook were no longer spelled in a runtime file.

Generated by Claude Code

claude added 9 commits October 8, 2026 08:34
The in-process handle (`bootStack` / `bootStackOnce`, `hooks.run`,
`flows.run`, `actions.run`, `seed`, `rows`, `metadata`, `tenancy()`) is the
door the suites will reach the real engine through. It resolved only as a
transitive dependency of the CLI until now.

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

`metadata-fixtures.ts` mixed two kinds of value. The authored ones — the
composed stack's collections, the locale packs, a tree walker — are views of
`composed-stack.ts` and now live there or in the suite that reads them. The
runtime ones were stand-ins for the platform: a platform-object roster
assembled from six package exports plus a hand-listed `AUDIT_PROVISIONED`,
and a hand-listed set of system columns for `fieldsOf`. Those now come from
the booted app's own registry (`metadata.objects()` / `metadata.object()`),
through `@objectstack/verify`'s `bootStackOnce` over the shipped artifact.

`test/helpers/verify-stack.ts` is the one boot every suite shares: a single
options constant so `bootStackOnce`'s identity memo can share it, and the
four capability plugins this app's `requires[]` resolves to under
`objectstack serve` that the lean boot was measured to omit (record-change
triggers, approvals, messaging, audit). `@objectstack/plugin-audit` is
declared at the exact pin because that file imports it.

Measured delta of the platform-object set (72 roster names → 73 registry
names): the registry adds the RBAC objects the security plugin registers
(`sys_permission_set`, `sys_position`, …) that the roster missed, and lacks
the storage/job/email objects only the always-on slate mounts. No reference
in today's metadata changes verdict; the 18 suites pass unchanged.

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

Six suites assembled their own kernel — memory datasource, metadata,
objectql, hand-registered identity objects, AppPlugin with skipSeedData,
security, sharing — and, because that assembly mounted no auth plugin, a fake
`tenancy` service from `tenancy-probe.ts` so the declared sharing rules would
seed. They now boot the shipped artifact with `@objectstack/verify`'s
`bootStack` (the real AuthPlugin's tenancy service, posture `single`), sign
their people up, give them positions and permission sets as system writes
(`signUpPerson`), write as them with `hooks.run`, read as them with `rows`,
and read each person's context from `contextFor`.

Two expectations moved with the means, the business fact unchanged:
- admin standing is read off the resolved context's `posture`
  (`PLATFORM_ADMIN` / `MEMBER`), the field the platform's resolver sets;
  `hasPlatformAdminGrant` was `buildContextForUser`'s.
- the boot replays the app's seed rows, so reach readings are taken over
  each fixture's own rows (seeded ownerless open cases and US accounts are
  reached by the same rules and are not the pinned population).

Two writes have no door on the 17.7.0 handle and keep one local path in
`verify-stack.ts`, the engine's own objectql on the verify-booted kernel:
a system-context UPDATE (`seed` only inserts) and a GUEST insert (no user,
not system; an unauthenticated POST /api/v1/data/crm_case answers 401).

`sharing-posture-declaration.test.ts` is deleted with the probe: it asserted
that hand-built kernels mount `tenancyProbe` before `SharingServicePlugin`,
its remedy text named the deleted helper, and with every boot going through
the platform its population is empty by construction (measured: 0 mounters,
its sentinel red).

Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3
Co-authored-by: Claude <noreply@anthropic.com>
`action-sandbox.ts` ran each action body and lowered hook body in QuickJS
over a stub ObjectQL that copied measured kernel rules. Every importer now
reaches the real engine of the shipped app booted through
`@objectstack/verify`: actions through `actions.run` (dispatcher gate, param
contract, subject load under the caller's scope, then the body in QuickJS),
hooks inside real writes through `hooks.run`, rows read back through `rows`.

The suite that proved the stub (`action-sandbox.test.ts`) also carried this
app's business facts; those move, ported, to `script-bodies.test.ts`
(every script action runs, mass_update_stage, clone / mark_primary / campaign
enrolment / mark_responded / send_email, the #678 actor name, the price fill,
the territory derivation). What it held that only proved the stub or the
platform runner is deleted with it, as are `harness-lookup-shape` and
`hook-write-shape`, the named proof suites that import the stub. The "every
hook still lowers" sweep is the platform's: `os lint --strict` refuses a
non-lowerable hook as `hook-body/not-lowerable` (measured by ablation on a
scratch copy: control exit 0, mutant exit 1). Body-text checks call the
platform's public `@objectstack/cli/hook-body` extractor directly.

Files that used this stub and `hook-harness` for the same scenario are ported
whole here (the quote-accepted trio, record-id-not-in-prose, refusal-envelope,
global-actions, do-not-call-enforcement, escalation-task-subject).

Paths kept in `verify-stack.ts` for what the 17.7.0 handle has no door for,
each reported upstream: a spy-based recorder of the writes the engine
receives (what a hook handed over; when an async hook finished; a staged
refusal), and the shipped-body runner the refusal-envelope suite pins the
production envelope on (platform extractor + platform runner over the real
engine). `@objectstack/plugin-email` is mounted (always-on `email`, which
`send_email` needs) and declared at the exact pin.

Expectations the real engine moved, the fact unchanged, each stated in its
test: a rep's quote acceptance cannot draft a contract (no crm_contract
create; the hook writes as the caller), so the draft suites accept as a
sales manager and the rep case is reported; a contact-less quote can no
longer be accepted (#1017), so those pre-images are pinned as the refusal;
required name/subject/number columns make several "blank half" branches
unreachable, pinned as the refusal that makes them so; the dispatcher refuses
undeclared params and a bare value for a multiple lookup before any body
runs; a fresh database boots value-shape strict, so warn-first is asked for
with OS_ALLOW_LAX_VALUE_SHAPES.

Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3
Co-authored-by: Claude <noreply@anthropic.com>
`flow-harness.ts` ran the real AutomationEngine over an array-backed
stand-in data engine (its own `$gt`/`$lt`, its own hook dispatch order, a
stub messaging service, no permission check). Every importer now runs the
shipped app booted through `@objectstack/verify`: screen flows through
`flows.run` / `flows.resume` as a real persona, record-triggered flows off
real writes (a person's `hooks.run`, a guest web-to-case/web-to-lead insert,
or a system write), sweeps through the trigger door as the admin, and
effects read back off the engine: rows, the run history
(`sys_automation_run` via `flowRuns`), the notification outbox
(`sys_notification_delivery` via `notificationsTo`) and the HTTP outbox
(`sys_http_delivery`). Flow variants a fact needs (a tampered gate, a
dead gate, a runAs-less twin) are registered through the platform's own
authoring door (`POST /automation`) and removed with `DELETE`; approvals
are decided through `POST /approvals/requests/:id/approve|reject`.
`flow-harness-declared-columns.test.ts` goes with the stand-in it proved.

Files that used this stand-in together with `hook-harness` are ported
whole here (flow-scheduled, knowledge-deflection, account-name-normalized-
match, the lead-conversion, qualification and status-change approval
gates). `readonly-write-semantics` and `audit-stamp-readonly` only took the
silent logger from it; they assemble their own real ObjectQL and keep it.

Sweeps process every match in the database, seed rows included, so each
reading is taken over the case's own rows (by id, by the notification's
action URL), or as a delta over a database settled by a first sweep.

Expectations the real engine moved, each stated beside its test:
- the start-condition truth tables run off real writes where a write can
  produce the shape, and through the engine (`runRecordFlow`) only where
  none can (an ownerless contact, a deal born pending, a vanished case);
- runAs-less variants of case_escalation, opportunity_approval and both
  approval gates are refused at the authoring door (422
  `flow-update-readonly-field`) instead of failing at run time;
- approvals now really open (the harness lacked the approvals plugin):
  runs park `paused` with a `sys_approval_request`; the "flow's own write"
  cases run end to end through a real decision;
- a user write carrying a readonly verdict is stripped and judged by the
  stored one (qualification INPUT-FIRST case flips to RECORD_LOCKED);
- an update addressed to a vanished case is refused (`Record … not
  found`), so that run fails at the escalation write, still notifying no
  one;
- `acted` counts tasks; notifications are `unmeasured`;
- no NULL amount, email-less lead/contact or blank owner from a person can
  be written; those shapes are pinned as the refusal (blank and numeric
  owners stay, as system writes);
- a bulk update binds each row's pre-image (no `previous: null`);
- a lead with a NULL match key converts onto an UNRELATED key-less account
  — the documented stop does not hold on either datasource; pinned as the
  measured defect and reported.

Local paths kept in `verify-stack.ts` for what the 17.7.0 handle has no
door for, each reported upstream: a predicate (bulk) update, and running a
record-triggered flow on a record the engine does not hold. Kernel services
the handle does not front are read directly where a fact needs them: the
automation service's own condition evaluator (`conditionHolds`), and the
sharing service's rule reconcile.

Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3
Co-authored-by: Claude <noreply@anthropic.com>
`hook-harness.ts` called each hook handler directly, with a hand-built ctx
and a `ctx.api` over plain arrays. Every importer now writes through the
shipped app booted by `@objectstack/verify`: the persona each case is about
writes through the engine's write door (`hooks.run`), prior states are put
in place by the system (`seed`, the one system-update path), anonymous web
submissions go through the form door's own execution context, and what is
asserted is the row the engine stored or the refusal it raised. Async hooks
are waited for; a claim that a hook wrote nothing is read off the writes the
engine received.

`guestInsert` now writes under the context the platform's anonymous form
door uses (the form's one-object grant, `guest_portal`, `anonymous`) rather
than an empty context no door produces. Under it the intake round-robins and
the duplicate lookup cannot read their pools, so the two guest-facing cases
that had read otherwise now pin the stored truth; `runRecordFlow` also covers
the user-less trigger a seeded record cannot fire.

Branches no write can reach (an unknown select value, an absent `ctx.api`, a
key-less row the schema requires) are pinned as the engine's refusal, or
re-aimed at the writer that does reach them. Where the real write measured a
defect the stand-in hid, the case is pinned as a measured defect beside the
same behaviour shown through a writer that holds the rights.

Deleted with the stand-in: `hook-input-shape` and `hook-query-predicate`, the
named suites that proved it and the engine shapes it modelled.

Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3
Co-authored-by: Claude <noreply@anthropic.com>
AGENTS.md cited `test/hook-query-predicate.test.ts` for the `where`/`filter`
engine behaviour and `test/action-sandbox.test.ts` for the lowering check;
both suites are gone with the stand-ins they proved. The lowering check is
`pnpm lint` (`hook-body/not-lowerable`), the engine behaviour is recorded in
`_hook-api.ts`, and the `mass_update_stage` body runs in
`test/script-bodies.test.ts`.

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

`runtime-coverage` requires every registered hook and flow name to appear in
code of a runtime test file. The ports drove `opportunity_line_item_price_fill`,
`billing_handoff_contract_activated` and the two per-person enrolment subflows
through real writes and runs without spelling their names: the price fill's
describe now carries the hook name, the contract hand-off case reads the
flow's own run history by name, and the enrolment suite asserts the screen's
two subflows by name and that both are registered on the booted app.

Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3
Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Oct 8, 2026 1:03pm UTC

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd CI plumbing and the verification pipeline dependencies Dependency bumps and lockfile changes labels Oct 8, 2026
}
};

const nodeStatus = (summary: Rec | undefined, id: string) =>
A test's title must say what its body asserts. Porting onto the verify
handle kept some base titles verbatim while the real engine turned the
asserted outcome around (allowed to refused, written to refused, omitted
to written). Each such case is retitled to what it now asserts.

Three are defects, pinned in the measured-defect form with a rewrite-me
message:
- a customer account whose deals are all closed still cannot be deleted
  (the guard says "Close or reassign it first"; closing is not enough);
- an account whose contracts are all draft, expired or terminated still
  cannot be deleted (cascade order);
- a contact whose only contract is expired still cannot be deleted (the
  guard says "Close or reassign those records first").

The rest are reworded to the outcome the body now asserts, mostly an
input the engine refuses before the hook could see it. No matcher changes (the three defect pins' failure
messages now carry the rewrite-me text); the contact case is split in
two so each half has a true title, and the whitespace-only fold row
moves to its own table.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 70084d9c56a4e6271050f7f581295673bcb15f3f
Local-runs: none

Inputs read: card #1595 (body, ruling 6051426954, claim 6055790691, os-dev-reports 6060172268 and 6060702334), PR #2013 body and file list, git diff origin/main...70084d9c (101 files, +10525/−13974; merge-base = 99d290ad, the PR's stated base), full files at both ends, the nine check-runs on the head (all success: Build and Test incl. test:coverage, Quality Checks incl. objectstack lint --strict, Check Changeset, CodeQL with 1 note, Playwright, link-check, labeler, Vercel), AGENTS.md at origin/main and at the head, and src/sales/objects/_hook-api.ts plus the pinned cli's hook-body-lowering.ts for the two sentences the hunk now cites. Nothing built, run or re-run.

① Derived judgments

A. The governed AGENTS.md hunk (two sentences, lines 133 and 138) — RIGHT, both.

  • Line 133, old: "test/hook-query-predicate.test.ts pins the engine per method" → new: "the engine's behaviour per method is recorded in _hook-api.ts". True of the head: the HookQuery doc block in src/sales/objects/_hook-api.ts records, method by method (find / findOne / count / update / delete), that filter is a live alias folded to where, that unknown keys throw, and the Conflicting options … 'where', 'filter' mixing hazard, measured 17.2.0 → 17.4.0. The downgrade from an executing pin to a dated record is the honest state once the suite is deleted, and the deletion is the card's own order: that suite pinned the PLATFORM's predicate semantics on a hand-built ObjectQL, which AGENTS.md § Scope rule 3 says this repo's tests do not own. The one repo-side fact it also held — "no file under src/*/objects/ writes a filter: key" — survives as the compile error HookQuery raises (no filter member) and, measured on the head, no src/*/objects/*.ts or src/*/actions/*.ts carries a filter: object key. Caveat, not a defect of the hunk: _hook-api.ts:55 and :158 still say the record is "pinned by an assertion in test/hook-query-predicate.test.ts" and "pinned in test/action-sandbox.test.ts" — both deleted here; src/** is outside this card's surface, so the pointer AGENTS.md now gives lands on a record whose own pin claim is stale until [finding] 26 source comments in src/ cite src/flows/ — a directory ADR-0130 removed (blocked on Track A: file surface collides) #1919 repoints it (③).
  • Line 138, old: "fails the lowering, which test/action-sandbox.test.ts runs over every registered hook" → new: "which pnpm lint (os lint --strict) refuses as hook-body/not-lowerable". True of the head: package.json lint is objectstack lint --strict; the rule NOT_LOWERABLE_RULE = 'hook-body/not-lowerable' has shipped in the cli since 17.3.0 (pin 17.7.0); its walk covers hooks, object actions and top-level actions, so the sentence's subject — "a hook handler and a script action body" — is fully covered, wider than the deleted sweep (hooks only); a module-scope reference is the free-identifiers class, graded error, and os lint exits 1 on an error. CI runs it in both Build and Test and Quality Checks, both green on the head. The dev's ablation claim (commit 49b229a3: control exit 0, mutant exit 1) is consistent and was not re-run.
  • The hunk changes two citations and nothing else in the governed file. The second edit, docs/developers/code_examples.md → test/script-bodies.test.ts, is accurate: that file pins mass_update_stage end to end, _selectedIds included (lines 162–240).

B. Deleted or rewritten tests vs "Zero tests deleted that pin a business fact" — RIGHT.

C. The 13 ⚠️ … (measured defect) cases (8 files) — an honest form, RIGHT; new to this repo (no precedent at base).
Each case has three parts: a doc block naming the mechanism and the 17.7.0 measurement (door, caller, what the engine answered); an assertion of the MEASURED outcome; and a failure message of the form "… — the defect is fixed: rewrite this case to pin the …". The title carries both the ⚠️ and "(measured defect)". So the asserted value cannot be read as intended behaviour, and the day the defect is fixed the case goes red and says what to do. Read against base: opportunity-account-capability-gate asserted a denied read THROWS and the gate fails closed — on a stand-in makeDeniedApi(); on the real engine record-level access FILTERS, the read is empty, the hook's "account cannot be found" branch stands down and the engine's reference check accepts the unreadable id (F11). hooks-runtime-sales:623/679/920 asserted the guards LET deletes through; the engine's required, non-cascading lookups (crm_opportunity.crm_account, crm_contract.crm_contact) refuse them with DELETE_RESTRICTED and the guards' own "Close or reassign" text is misleading (F8/F17/F18) — pinned as engine-not-guard with expect(err.message).not.toContain('Cannot delete customer account'). knowledge-feedback:315, campaign-member-lifecycle:241, hooks-runtime-service:243/568/701, demo-staffing:478, case-assignment:403 are one family: a hook writes cross-object data through ctx.api AS THE CALLER, who lacks the right; onError: 'log' swallows the refusal (F12/F13/F15a-c/F14/F9). hooks-runtime-service:720: on SQL the membership projection omits id so no row is ever promoted (F16). account-name-normalized-match:558: a null match key resolves to = NULL and the lead converts onto an unrelated key-less account (F7). These are the base's stand-ins having PASSED a fact the shipped app does not deliver, which is the card's whole premise; pinning the measurement with a rewrite-me message is the right call for a card that may not touch src/**. Two notes: the form is only honest while each pin is routed to a card (③), and guest-submission-sanitisation "overwrites every internal field the guest branch claims to strip" flipped its secondary owner_id assertion (toBe(id.agent) → toBeNull()) with an explanatory comment but no ⚠️ title — tolerable because its titled claim (the strip) still holds and the fact is pinned as a defect in case-assignment.test.ts:403.

D. test/helpers/verify-stack.ts vs "⛔ Never re-grow a local stand-in" — RIGHT, with one path to watch.
Nothing in the file models the engine: no array store, no predicate matcher, no HookContext builder for the in-process path, no stub messaging. bootOptions / hotcrmStack wrap bootStackOnce (the platform's memo — card item 1's "no local memo" holds); signUpPerson provisions RBAC rows through seed; flowRuns / notificationsTo read the engine's own sys_automation_run / sys_notification_delivery; systemUpdate (25 importers), predicateUpdate (1), guestInsert (11), runRecordFlow (2) and conditionHolds (4) each call the verify-booted kernel's own objectql or automation service with a stated execution context; recordEngineWrites (14) is a pass-through vi.spyOn on the real insert / update / delete with optional fault injection — an observer, not a stand-in. The hand-list extraPlugins (triggers, approvals, messaging, audit, email) exists because CAPABILITY_PROVIDERS is a static readonly on the cli's Serve command class and is not exported — confirmed in the pinned source. The one path that hand-assembles a hook ctx is runShippedHook (1 importer, refusal-envelope): it builds { event, input, previous, user, executionContext, object } for the platform's own hookBodyRunnerFactory-bound handler over the real engine, because the handle boots the source config and never runs a lowered body. That is the closest thing in the diff to a re-grown makeCtx; it is acceptable only as gap 7 and should be the first path to retire when the handle grows a built-artifact door. Every path's docblock says it is "reported upstream" — that is ahead of the facts (③).

E. Dependency additions vs the pin rule — RIGHT. @objectstack/verify, @objectstack/plugin-audit, @objectstack/plugin-email at exact 17.7.0 in devDependencies (the precedent is @objectstack/formula), no ^; pnpm-lock.yaml adds exactly those three importer entries. Every @objectstack/* imported under test/ (21 packages, git grep on the head) is declared in package.json — card item 4 and its acceptance line hold (item 4's bulk had already landed as #1769). The card's AIM that verify be the only test-side entry besides spec is not met (objectql 43 imports, driver-memory 26, runtime 8, cli/hook-body 10, …) and the PR body says so; the hand-built ObjectKernel suites fell 12 → 6 files and the rest are outside this card (③, OQ1).

F. Scope and gates. No src/** change (diff confirmed); vitest.config.ts untouched, so thresholds are unchanged, and Build and Test runs pnpm run test:coverage → success on the head, which answers "met". CodeQL's one note is a nit: nodeStatus declared and unused at test/flow-record-change.test.ts:549. The OS_ALLOW_LAX_VALUE_SHAPES toggle in quote-accepted-lookups.test.ts:350 is scoped to one case whose subject IS the warn-first posture and restores STRICT in finally — not shape tolerance.

② Semver level

hotcrm is "private": true and the diff publishes nothing user-visible: tests, devDependencies, the governed AGENTS.md, and an internal page under docs/developers/. The changeset .changeset/1595-tests-on-verify-handle.md has empty frontmatter, the form this repo uses for a test-only change; Check Changeset is green. Clause-②: no holds — no accept set is widened, no public surface is enlarged, nothing published narrows, so no direction arm is owed. Matches.

③ Boundary flags

Dev report 6060172268 — two open_questions; 6060702334 — none, plus one "reported, not retitled" item. Each answered below; the items the diff cannot settle are escalated to the seat.

  1. OQ1 — test/helpers/ ends with 11 files, not the card's four. Answer: A, accept. The card's "only four" line was written against the 2026-09-05 measurement (021db549); the six static readers (composed-stack, src-roster, config-globs, flow-regions, registration-lists, identity-objects) were added by other cards before this one dispatched, none executes app behaviour, and deleting them would have breached the card's own "deletions are exactly the stand-ins and their self-proofs". verify-stack.ts is what the card's item 5 permits ("keep that one local helper path until the fix is pinned") — on the condition in flag 4. The remaining hand-built ObjectKernel suites (6 files, identity-objects with them) are a separate migration; a follow-up card may carry B's second half.
  2. OQ2 — keep the AGENTS.md repoint in this PR? Answer: A, keep. Both sentences are accurate (①A) and dropping the commit would leave the SSOT citing two files that no longer exist. Consequence the seat must honour: AGENTS.md is a governed path, so per AGENTS.md § How a green PR lands the PR stays a draft and is the maintainer's own merge — ⛔ the seat neither flips it ready nor arms auto-merge.
  3. "Dropped half" at global-actions.test.ts:483 and script-bodies.test.ts:437 ("reads sys_user once — and only because the dispatcher delivered no name"). Answer: accept as is. The body asserts exactly one sys_user read and the right name; the clause after the dash states the body's REASON, which is still true on 17.7.0, and the comment names the retirement condition (reads 0 with the name still right). No reword required.
  4. ESCALATE — the 8 platform gaps are not filed. Card item 5: "file it on objectstack citing this card … and report the count". The count (8) and the list are reported; no objectstack issue exists (the dev's api_writes show none; the dev hands the carrier to the seat). Until filed, every "reported upstream" docblock in verify-stack.ts is ahead of the facts and the "until the fix is pinned" clause has no anchor. Seat to file on objectstack citing hotcrm#1595 (one card listing the eight, or one per gap: CAPABILITY_PROVIDERS export; system UPDATE door; predicate multi: true door; /forms/:slug/submit on the handle; user-less / vanished-record trigger door; engine-write observation incl. async completion and staged refusal; lowered-body door; automation.evaluateCondition), then a hotcrm follow-up cites the numbers beside each path. Gap 9 (sharingRules.evaluateRule called as a kernel service in flow-case-actions / unassigned-case-triage-reach while POST /sharing/rules/:id/evaluate works) is a hotcrm cleanup follow-up, not a platform gap.
  5. ESCALATE — the pinned defects need cards, F11 first. The ⚠️ form (①C) is honest only while each pin is routed. Route per the dev's grouping: F11 (a caller who cannot read a Settlement-Only account opens a deal on it — a security fail-open at opportunity.hook.ts:540-544, class b) → its own hotcrm card, prioritised; F8 + F17 + F18 → one hotcrm family card (deletion intent vs the engine's required-lookup referential rule; the guards' "Close or reassign" wording); F1 + F12 + F13 + F15a-c + F14 → one hotcrm family card (hooks writing cross-object derived data as the caller; onError: 'log' swallows the refusal; F14 is the read-side twin); F7 (null match key → = NULL → data mixing across customers) → hotcrm card, with the platform half (a null token resolving to a NULL-equality filter) cited upstream; F9, F16 (sqlite projection drops id), F4 (seed cel dates refused under the verify boot), F6 (get_record projection widened; platform columns reach the billing endpoint) → objectstack; F10 (unpinned) → hotcrm card. The seat files; this review is read-only.
  6. Stale src comments → [finding] 26 source comments in src/ cite src/flows/ — a directory ADR-0130 removed (blocked on Track A: file surface collides) #1919. The dev's list stands; add that _hook-api.ts:55 and :158 are the two the new AGENTS.md line 133 now points readers at, so they are the first to repoint.
  7. feat(picklists): the shared option lists become picklist metadata — *.picklist.ts and Field.select({ picklist }) replace _picklists.ts (acceptance of objectstack#18164) #2000 coupling. Ruling 6051426954 ordered this card AFTER feat(picklists): the shared option lists become picklist metadata — *.picklist.ts and Field.select({ picklist }) replace _picklists.ts (acceptance of objectstack#18164) #2000; the claim inverted that because feat(picklists): the shared option lists become picklist metadata — *.picklist.ts and Field.select({ picklist }) replace _picklists.ts (acceptance of objectstack#18164) #2000 is pm:blocked. Seven files overlap and feat(picklists): the shared option lists become picklist metadata — *.picklist.ts and Field.select({ picklist }) replace _picklists.ts (acceptance of objectstack#18164) #2000's versions still import deleted stand-ins; on resume feat(picklists): the shared option lists become picklist metadata — *.picklist.ts and Field.select({ picklist }) replace _picklists.ts (acceptance of objectstack#18164) #2000 rebases onto the handle (metadata / hooks.run), not onto the deleted helpers. Seat to note this on feat(picklists): the shared option lists become picklist metadata — *.picklist.ts and Field.select({ picklist }) replace _picklists.ts (acceptance of objectstack#18164) #2000.
  8. Deletion disclosed beyond the card's list (sharing-posture-declaration, ①B) — accepted; recorded here so the acceptance line "deletions are exactly the stand-ins and their self-proofs" is read with this one, which is a self-proof of the deleted tenancy-probe.
  9. Nits, no action owed for the verdict: CodeQL's unused nodeStatus (flow-record-change.test.ts:549); the guest-submission-sanitisation secondary flip without a ⚠️ title (①C).

Implemented-by: claude/issue-1595-verify-handle
Reviewed-by: session_012zh91QzFgePbkmuHnugLN3

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet objectstack-fleet Bot added the needs-user-decision Needs the maintainer's call before work proceeds label Oct 8, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

repo:hotcrm 席位定稿,2026-10-08T13:39Z。审核记录:契约复核 PASS(本 PR 评论 6061093953),卡 #1595 的 ACCEPT 评论。

改了什么
测试从此在平台的真引擎上跑,不再用 hotcrm 自己手写的替身。

  • hook、flow、action 三类测试,全部通过 @objectstack/verify 17.7.0 启动真正发布的应用(双包产物),走平台自己的入口:以某个具体用户写入、触发并续跑 flow、调用 action。
  • 删掉 5 个手写替身和 7 个只为证明「替身像引擎」的自证套件,共 12 个文件、5,590 行。
  • 对用户:0 改动。不碰 src/,不发版(changeset 为空)。
  • AGENTS.md 只改了两处引用,因为原来指向的套件被删了。这是本 PR 走维护者审批的原因之一;另一个原因是改动 24,405 行,超过 5,000 行的人合线。

为什么改
落实 2026-09-05 你的 B′ 裁决(「把执行能力并进 @objectstack/verify」),以及今天批 hotcrm-R74 第 4 项 B。旧替身没有权限检查,ctx.api 底下只是普通数组,所以有一批真实缺陷被它掩盖了。

风险与代价(含回滚)

  • 风险低。 CI 9/9 全绿;pnpm verify 本地全绿,3,564 个测试通过;覆盖率阈值不变,四项读数都比 base 略高。
  • 代价一:钉了 13 个「⚠️ 已测缺陷」用例。 真引擎暴露出的缺陷,这些用例断言的是「现在的错误行为」,并带一句「修好了就改写本用例」。所以缺陷修好时它们会变红,提醒有人去翻转。它们不是业务事实。
  • 代价二:真引擎上测不到的场景,若干用例换了测法。 例如报价折扣从 70% 改为 34%,因为平台有 60% 的上限;线索转化改由经理执行,因为业务员会被拒(这本身是 F3 缺陷)。每处都在 PR 正文和用例旁写明。
  • 代价三:test/helpers/verify-stack.ts 保留 8 条本地路径。 平台 handle 目前缺这 8 个入口,已立上游卡 objectstack#22301。这些路径只调用引擎自己的服务,不重写引擎。
  • 跟 feat(picklists): the shared option lists become picklist metadata — *.picklist.ts and Field.select({ picklist }) replace _picklists.ts (acceptance of objectstack#18164) #2000 有冲突: 它恢复时要把 7 个重叠的测试文件改到 verify handle 上。
  • 回滚: 直接 revert 本 PR,只影响测试。

这次暴露出的产品缺陷(已立卡,不在本 PR 修)

席位意见
建议合并。理由:

  • 契约复核 PASS,CI 全绿。
  • 测试第一次跑在真引擎上,这本身就是收益:一次性挖出了 16 条真实缺陷。
  • 继续留着替身只会继续把它们藏起来。

你要做的(一个动作)
在 PR #2013 上点 Approve。批准后由席位转正、进合并队列落地。若你不同意某个「已测缺陷」的钉法,或不同意 AGENTS.md 的那两处改动,留一句话即可,PR 保持草稿。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 8, 2026 13:48
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 9451b6d Oct 8, 2026
11 checks passed
@objectstack-fleet objectstack-fleet Bot removed the needs-user-decision Needs the maintainer's call before work proceeds label Oct 8, 2026
This was referenced Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline dependencies Dependency bumps and lockfile changes documentation Improvements or additions to documentation

Projects

None yet

3 participants