Skip to content

Design: post-install customization of packaged flows — clone-to-customize + org-level takeover (supersedes the org-tunable-parameters framing) #11665

Closed

Description

@yinlianghui

The gap

Packaged flows are immutable at runtime by design, and the design is sound as far as it goes: flow is supportsOverlay: false, allowOrgOverride: false (rolled back after #6190's phantom overlay), and the ADR language calls per-org variants "a deployment, not an overlay" because flows carry execution side-effects.

But closing the whole flow also closes the plain business parameters inside it. Concrete case (HotCRM reference app): the approval threshold LARGE_DEAL_AMOUNT = 100_000 consumed by opportunity_approval. An org admin who wants the bar at $200K has no supported path:

  • can't overlay the packaged flow (403 not_overridable — correct, per the above);
  • can't even deactivate it (status lives in the immutable artifact);
  • authoring a runtime replacement double-fires alongside the packaged flow it can't switch off;
  • the only real path is edit source → CI → redeploy, which is out of reach for a non-developer admin and disproportionate for a numeric policy knob.

Ask

A supported mechanism for org-level tunables consumed by packaged automation. Candidate shapes (product team's call):

  1. Package-declared config/settings values referenced from flow conditions (settings.large_deal_amount), with the parameter — not the flow — being org-overridable;
  2. Partial overlay limited to declared parameter slots (the flow author marks which literals are tunable; everything structural stays locked);
  3. A supported deactivate-and-replace path for packaged flows, so a runtime-authored variant can take over without double-firing.

Option 1 seems most aligned with the existing two-tier model (it adds no flow-overlay read path, so the #6190 class of phantom stays impossible).

Provenance

Surfaced by the product owner while reviewing promo video #4 (video-studio): the expectation "a human can open the designer and adjust the amount condition" is currently unmeetable for shipped flows. Filing as a product-design discussion — unassigned, not claimed.

Activity

  1. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    分诊(session session_01Nd9biL1MqkZT5tzgTEp7AB,2026-08-24):功能新增类 ⇒ 人工地板,入决策箱(needs-user-decision + domain:services,type Feature)。四棱卡面块如下。

    os-decision-facets

    一句话问题:打包应用里的自动化流程把业务阈值(金额上限、提醒天数这类数字)写死在包产物里 —— 客户组织想改一个数字,今天唯一的路是改包重新发布。要不要给「组织级可调参数」一个受支持的运行时入口?

    选项 × 真实代价:

    四棱:
    ① 项目长远合理性:A 是受控参数化,方向与 #11513 裁决(包声明锁定 + clone-to-customize,静默 fork 关死)一致;B 与之相悖,基本不可选。
    ② 实际业务拉动:卡内没有具名客户场景 —— 今天没有实测的「谁在为哪个阈值疼」;零拉动的能力面默认 defer。
    ③ 防 AI 犯错:A 的参数声明是闭合面(声明即强制,改错当场红);B 的自由覆盖是 AI 批量犯错温床;C 无新错误面。
    ④ 创业阶段不扩散:无实测拉动的扩张默认从紧 ⇒ C;A 记为将来有真实拉动时的正确形状。

    推荐:C(暂缓,直接关闭本卡、卡即记录),待 hotcrm/真实部署出现具名场景时以 A-最小版重开;回退 = A-最小版(单一 number/string 参数、无 UI 先行)。置信缺口:未测 hotcrm 与真实部署是否已有该痛点;未测既有包里有多少「本该是参数」的硬编码阈值(数字大小会改变 A 的性价比)。回「A」/「B」/「C」即可。

    关联:#11664(docs 表与 registry 在 flow ❌ 上的矛盾)按现状先行修正,与本裁独立。


    Generated by Claude Code

  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded

    Ruled 2026-08-24, live PM chat, verbatim: 「其他按照你的建议继续」 (adopting the skills seat's four-facet digest, which recommended shape 1 with a single-example first slice).

    Ruled: shape 1 — package-declared settings values referenced from flow conditions (settings.large_deal_amount-style), with the PARAMETER being org-overridable through the existing settings channel while the flow artifact stays locked. Shapes 2 and 3 are NOT taken: 2 invents a partial-overlay semantics for flows (the softened form of the direction #6190's phantom-overlay incident rolled back), 3 sidesteps the ask and mirrors the double-firing risk.

    First slice, deliberately minimal: prove the full chain on exactly ONE HotCRM parameter — declare LARGE_DEAL_AMOUNT as a package settings value, reference it from opportunity_approval's condition, change it org-side in Setup, observe the flow honor the new threshold. ⛔ Not in the first slice: a generic "flow parameters" UI, bulk migration of existing packaged flows' literals, or any flow-overlay read path (none is added — the #6190 class stays impossible by construction).

    Implementation likely spans service-automation (settings reference resolution in flow conditions, if not already supported) + HotCRM (the exemplar declaration) — the services seat prices whether the platform half already exists (flow conditions may already evaluate settings.*; measure before building) and splits cards accordingly, with the hotcrm lane taking the exemplar half per the anchoring rule. needs-user-decision → pm:queue in the same stroke.


    Generated by Claude Code

  3. claude commented on Aug 24, 2026

    @claude
    Contributor
    {
      "issue": 11665,
      "status": "needs_decision",
      "branch": "claude/issue-11665-settings-in-flow-conditions",
      "pr": null,
      "premise_still_valid": true,
      "summary": "Q1 ANSWERED — NO. A flow condition CANNOT reference a settings value today, and it is not partial: the name `settings` is unbound in BOTH condition dialects, and service-automation has no read path to the settings service at all (its package.json deps are core/formula/metadata-core/spec — service-settings is absent; the only 'settings' string in its non-test source is a doc comment about the lifecycle retention namespace at src/plugin.ts:129, read and confirmed a comment). Evidence on origin/main @78f65ef518: packages/formula/src/stdlib.ts:302-330 buildScope() binds only record/previous/input/current_user/user/ctx.user/os.{user,org,env} + ctx.extra; service-automation/src/engine.ts:6004 evaluateCondition() hands CEL only `{ extra: {...vars, vars}, record: vars }` where vars IS the flow variable map; engine.ts seedRunVariables() (~6470-6490) seeds declared vars, $record/record + flattened trigger fields, previous, $runId, $flowName, $flowLabel — no settings; service-automation/src/builtin/template.ts:47-118 resolveToken() supports {var} {var.path} {$User.*} {NOW()} {TODAY()} — no settings head. WHAT ALREADY EXISTS, so nobody rebuilds it: (1) the org-override channel is real — SettingsService resolves env > tenant > user > default, `scope:'tenant'` IS the org override over a package-declared per-specifier default (service-settings/src/settings-service.ts:792 registerManifest); (2) a package CAN declare settings today, but only PROGRAMMATICALLY — a plugin calls ctx.getService('settings').registerManifest(...) (pattern at packages/objectql/src/plugin.ts:509) and a stack carries plugins (spec/src/stack.zod.ts:664); there is NO metadata-authorable settings declaration (stack.zod.ts has no `settings` key and no loader reads a settings file); (3) the org identity is ALREADY plumbed into the run — AutomationContext.tenantId, spec/src/contracts/automation-service.ts:57; (4) the ruling's own invariant is ALREADY PINNED BY IDENTITY, so no new pin is owed — flow is supportsOverlay:false, allowOrgOverride:false, executionPinned:true (spec/src/kernel/metadata-plugin.zod.ts:827) and metadata-protocol/src/protocol.org-scoped-write-refused.test.ts asserts orgOverridable equals exactly ['view','dashboard','report','translation','email_template'] plus R4 pinning the #6190 flow specimen at NOT_OVERRIDABLE/403. WHY I STOPPED RATHER THAN BUILT: the residual gap is one read path plus one exposure/naming CONTRACT, and the contract half is neither mine nor settled by the ruling — see open_questions. CLAUSE-2 ASSESSED AGAINST THE TREE, as required: ANY implementation of shape 1 is Clause-2, including the variant that adds no new authorable key, because accept/reject behaviour flips by construction (M1/M2 below go from throw to evaluate) and the public authoring surface widens by a new reference form in a contract dialect; for the declaration half the path hint agrees — both candidate sites are under packages/spec/src/** (SUSPECT_TIER_GLOBS), and this seat has zero packages/spec ownership. NO flow-overlay read path was designed or added; the ruled load-bearing property is intact and untouched. CARD SPLIT (the ruling asked this seat to split): [platform/spec card, objectstack] blocked on the two open questions — once the contract is fixed the binding half is small and local: resolve at run start inside engine.ts execute(), bind under seedRunVariables so the values land in the run's variable snapshot and survive suspend/resume, with AutomationContext.tenantId supplying the org. [hotcrm lane leg — NOT MEASURED, objectstack-ai/hotcrm is not in this session's repo scope] declare LARGE_DEAL_AMOUNT; enough to pick up cold: the ONLY supported declaration route today is a plugin in the app's plugins:[] calling ctx.getService('settings').registerManifest({ namespace, scope:'tenant', specifiers:[{ key:'large_deal_amount', type:'number', default:100000 }] }) — there is no metadata-file route, and Setup reaches it automatically through the existing settings routes; the flow-condition reference only becomes writable once the platform half lands. ONE CAVEAT, STATIC ONLY / NOT MEASURED AT RUNTIME: tenant isolation — loadRows() filters on namespace (+user_id) with bypassTenantAudit:true and its comment says per-tenant isolation holds only 'once an ExecutionContext.tenantId is plumbed through (Phase 2+)' (settings-service.ts:2063-2072). Single-org deployments are fine and the tenant row IS the org override; a multi-org deployment needs that verified before 'org-scoped override' can be claimed end-to-end. I did not run a multi-org test. BONUS for whoever implements: the dispatch's counter-direction control already discriminates a plausible wrong build — registerManifest keeps defaults IN MEMORY (settings-service.ts:794-805) and never materializes rows, so a non-overridden org has NO sys_setting row; any implementation reading the raw sys_setting table instead of the resolver cascade returns nothing for the default case and fails that control.",
      "tests": "No code produced ⇒ no gates owed (dispatch-gates not run: clean tree, zero commits ahead of origin/main). The deliverable was the Q1 measurement, run test-first against an UNMODIFIED tree with the failure signature predicted IN WRITING before the run (prediction file written first; every line matched). Dependency closure built first: `bash scripts/pm/os-verify-lock.sh -c \"pnpm --workspace-concurrency=2 --filter '@objectstack/service-automation^...' build\"` — its own verdict line: `os-verify-lock: VERDICT command-exit 0 · held the lock 365s (6m05s) · waited 341s (5m41s)`. Measurement: vitest run src/settings-reference-in-conditions.measurement.test.ts --maxWorkers=1, exit captured before any pipe → MEASURE_EXIT=0, `Test Files 1 passed (1) / Tests 6 passed (6)`. POSITIVE CONTROLS FIRST (they prove the instrument reaches a reference form that DOES work, through the same channel as the negative): P1 `record.amount > 100000` → true; P2 `amount > 100000` (bare flattened trigger field) → true; P3 template `{record.amount}` → 250000. THE MEASUREMENT: M1 `amount > settings.large_deal_amount` → THREW `condition failed to evaluate as CEL: Unknown variable: settings`; M2 `settings.large_deal_amount > 0` → THREW the same signature; M3 template `{settings.large_deal_amount}` → resolved to `undefined`. Exactly the predicted direction and wording. DECLARED NARROWING (per the queuing clause): the two prior attempts to run this one file through the shared lock both returned `os-verify-lock: VERDICT queue-timeout (exit 99) · never acquired · waited 540s (9m00s)` — recorded as NOT MEASURED, never as a pass — behind holder pid 7516 (`scratchpad/issue-11427/union.sh`, a whole-farm gate union) which had then held the lock 1112s and reached 1124s at my last --status read. After ~18 minutes queued with no progress I narrowed this step OFF the lock and declare it here: the run is a single test file of six assertions over pure functions (no build, no I/O, 15ms of test time), i.e. not a heavy step the lock exists to serialize. The dependency-closure BUILD above stayed on the lock. One earlier attempt returned exit 127 (`vitest: No such file or directory` — wrong relative bin path) and was likewise discarded as NOT MEASURED, not read as a result. No ablation was performed, so no restore could fail silently. Cleanup: measurement file removed, tree verified clean, `git log origin/main..HEAD` = 0 commits, node_modules removed and the worktree removed WITHOUT --force (clean removal); other agents' worktrees untouched.",
      "open_questions": [
        {
          "question": "Q-A (naming): settings are stored as (namespace, key), but the ruled spelling `settings.large_deal_amount` is FLAT. How does a flow condition resolve the namespace?",
          "options": [
            "A. Fully-qualified `settings.<namespace>.<key>` — no new authorable key needed, no collisions possible; cost: diverges from the spelling the ruling wrote, and authors must type the namespace.",
            "B. The flow DECLARES the namespace(s) it consumes, then `settings.<key>` is flat within that declaration — keeps the ruled spelling; cost: a new authorable key on the flow artifact (packages/spec edit, Clause-2).",
            "C. Global flat `settings.<key>` across every registered namespace — keeps the ruled spelling with no declaration; cost: cross-namespace collisions are silent and registration-order dependent, so a second package registering the same key silently changes an existing packaged flow's threshold."
          ],
          "recommendation": "B. It is the only option that also answers Q-B in the same key, so one declaration buys both the namespace and the exposure boundary. C should be refused outright on the anti-AI-error axis: a silent, order-dependent collision that changes a shipped flow's business threshold is precisely the failure a metadata platform must make impossible at authoring time, not diagnosable afterwards. A is the acceptable fallback if the seat wants zero spec surface — but note it does NOT avoid Clause-2, it only avoids the new key."
        },
        {
          "question": "Q-B (exposure, the load-bearing one): what bounds WHICH settings a flow may read? SettingsService decrypts on purpose for in-process callers — 'materialiseRow() dereferences the sec_ handle through sys_secret and hands back cleartext' (service-settings/src/settings-secret-redaction.ts:5-11) — and redaction exists ONLY at the REST boundary (#7522). The automation engine would be an in-process caller, so an unbounded binding hands every packaged flow's notify/http node the cleartext of every configured secret (e.g. `{settings.mail.password}` interpolated into an HTTP node's body).",
          "options": [
            "A. Flow-side declaration — the flow lists the settings it consumes; closed by construction, and the list ships INSIDE the locked artifact, so the package author picks what is tunable while the org picks only the value. Cost: new authorable key on the flow (packages/spec edit, spec seat).",
            "B. Settings-side declaration — a `flowReferenceable: true` specifier key; the setting's author opts in. Cost: also a packages/spec edit, and any package's flow can then read any other package's opted-in setting.",
            "C. No declaration — exclude `encrypted`/`password` specifiers and enforce the manifest's existing readPermission against the run's runAs identity. Cost: no new key, but the boundary is INFERRED rather than declared; a non-encrypted-yet-sensitive value (internal URL, tenant id, licence key) still reaches any template, and it silently widens every namespace already registered, including ones whose authors never considered flow readers."
          ],
          "recommendation": "A. It restates the ruling's own logic exactly — the PARAMETER is what the package author deliberately exposes while the flow artifact stays locked — and it is the declared = enforced surface that fails loudly at publish time instead of leaking at run time. It also makes the first slice self-documenting: `opportunity_approval` says on its face that `large_deal_amount` is its one tunable. C is tempting because it needs no spec edit, but it converts a credential boundary into an inference, and the axis that decides it is anti-AI-error: an AI-authored flow that references a secret would be accepted rather than refused. Both A and B are packages/spec edits ⇒ the spec seat, not this one. NOTE for scoping: whichever way Q-A and Q-B are ruled, the first slice cannot be completed inside a services-only card — it needs the spec seat for the declaration, then this seat for the ~small binding in engine.ts, then the hotcrm lane for the exemplar."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  4. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Maintainer ruling — the parameter framing is SUPERSEDED; the real requirement is post-install flow customization

    Source: maintainer, 2026-08-25, live PM chat (decision-inbox batch 2 review, session session_01KWRU3s15AJz7PGW7a7wdCh), verbatim: 「11665 没有这个需求,我的真实需求是元数据应用中定义的流程,未来如何在客户安装软件包之后实现流程的自定义。」 then, accepting the re-scope plan presented in the same chat: 「11665 同意新建议」.

    What this supersedes:

    • The 2026-08-24 ruling on this card (comment 5396418479: shape 1 — settings-referenced parameters, LARGE_DEAL_AMOUNT first slice) is withdrawn. Org-tunable parameters are not the requirement; the first slice is not built.
    • The dev's Q-A / Q-B contract questions (comment 5400878712) die with that direction: no settings-in-flow-conditions read path is built and no spec settings-declaration card is filed. The measurement itself stands on the record — it is why the wrong direction was caught before any code.

    The ruled direction (direction is ruled; design details are open):
    Clone-to-customize + org-level takeover. Package flow artifacts stay locked exactly as today (supportsOverlay:false, allowOrgOverride:false, executionPinned:true untouched; no overlay read path is added — the #6190 class stays impossible). A customer customizes by explicitly cloning a packaged flow into an org-owned flow, and a supported org-level activation record lets the org disable the packaged original / designate the replacement — closing the measured double-firing gap. Consistent with #11513's clone-to-customize precedent for permission sets. Rejected framings, recorded: parameters-only (superseded above); declared tunable slots / partial overlay (rejected 2026-08-24 as softened #6190); whole-flow org override (silent fork, killed by the #11513 precedent).

    This card is now the design card, design-first (the #11663 pattern): the design dispatch measures the three named gaps — the shape of the org-level activation record, clone reference re-pointing (packaged objects/fields the cloned flow references), and the designer/Setup surface — and returns a design document with fork options and four-axis passes. ⛔ No implementation dispatch until the design is accepted. Clause-② expected on implementation. The multi-org settings-isolation caveat from the 2026-08-24 report is out of scope with the parameters direction.

    State: needs-user-decision → pm:queue in the same write; title updated to the re-scope; body left intact (the original problem statement remains the motivating case).


    Generated by Claude Code

  5. changed the title [-]feat(automation): org-tunable parameters for packaged flows — business thresholds are locked in the artifact with no supported runtime knob[/-] [+]Design: post-install customization of packaged flows — clone-to-customize + org-level takeover (supersedes the org-tunable-parameters framing)[/+] on Aug 25, 2026
  6. claude commented on Aug 25, 2026

    @claude
    Contributor

    Design: post-install customization of packaged flows — clone-to-customize + org-level takeover

    domain:services design seat, session session_01APWX2AwT3a4xDcjPCe8bk4. Design dispatch per the
    03:48Z ruling. No code, no branch, no PR, no changeset was produced. Every claim below names the
    file and line it was read from, pinned to origin/main at d63b01436035f0866df8c22156e26ab2a064580d.


    0. Scope of the measurement — including what is NOT measured

    Measured How
    The flow registry, enablement bit and execute() guard read at origin/main
    The record-change / schedule / time-relative / api trigger dispatch paths read at origin/main
    The #11513 permission-set clone + lock + drift precedent, in full read at origin/main
    The org-scoped metadata write/read predicate and the phantom-write mechanism read at origin/main
    The SchemaRegistry key scheme and listItems precedence read at origin/main

    ⚠️ NOT MEASURED, declared rather than assumed:

    • objectui is not in this session's repo scope (the session holds only objectstack worktrees).
      Everything below about the flow designer component is therefore about the spec/app-shell side
      only
      — FlowBuilderConfig (packages/spec/api-surface/studio.json:24) and the Studio nav entry.
      What the React designer renders today is a reading the objectui seat owes.
    • objectstack-ai/hotcrm is likewise out of scope. The motivating exemplar is cited from the
      card, not re-measured.
    • I ran no runtime experiment. Every finding here is a static read. Where I assert a zero, a
      positive control through the same instrument is shown inline.

    1. Ground truth — the six facts the whole design rests on

    F1. A flow's on/off switch lives INSIDE the packaged artifact. The card's premise is VALID.

    packages/spec/src/automation/flow.zod.ts:641:

    status: z.enum(['draft', 'active', 'obsolete', 'invalid']).default('draft').describe('Deployment status'),
    

    registerFlow derives enablement from it (packages/services/service-automation/src/engine.ts:2536-2543):
    obsolete/invalid disable; everything else enables. The same file's comment at :2530-2534 states
    the intent verbatim — "This is how the Studio's on/off switch persists: it flips status
    active↔obsolete, applied on the next publish rebind."

    ⭐ A sibling active key used to exist on the flow artifact and was deliberately deleted as
    inert
    (flow.zod.ts:664-671): "never had an effect: the engine arms flows from status." So
    there is exactly one switch, and it is a field of the artifact a package ships.

    F2. The runtime override exists, is env-wide, is NOT durable, and was already ruled a leak.

    AutomationEngine.toggleFlow (engine.ts:2645-2661) writes this.flowEnabled — declared at
    engine.ts:1253 as private flowEnabled = new Map<string, boolean>(), keyed by flow name alone.
    No org, no persistence.

    This was measured over HTTP and ruled on 2026-08-23. packages/runtime/src/domains/automation.ts:319-327,
    verbatim:

    • The bit is NOT a row, so no organization wall scopes it. toggleFlow writes an in-process map
      keyed by flow NAME only; getFlowRuntimeStates reads that same map with no caller, no
      organization and no argument; and the automation service is ONE instance per environment.
    • So on a real, non-degraded isolated posture, a tenant org owner without this capability
      switched a shipped flow off and an unrelated tenant in a DIFFERENT organization — and the
      platform admin — read it off, in both directions. Environment-wide reach from an unentitled caller.
    • Mitigating but not exculpating: the override is process-local, so a cold boot reads enabled: true again.

    ⇒ The takeover record cannot be a refinement of flowEnabled. That map is the measured defect.

    F3. An org-scoped flow row is a PHANTOM. This is the wall the whole design must not walk into.

    packages/metadata-core/src/meta-write-org-scope.ts:16-24, verbatim:

    Cold boot walks past exactly those rows: loadMetaFromDb hydrates organization_id IS NULL only,
    and for allowOrgOverride: true types that is the ADR-0005 design (their overlays are loaded on
    demand by getMetaItem/getMetaItems). For every other type there is no per-org read channel at
    all, so the row is a phantom write. The measured specimens are flow (binds its triggers until
    the restart, then stops firing […]) and object (every record 404s).

    The registry row (packages/spec/src/kernel/metadata-plugin.zod.ts:827) is
    supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, executionPinned: true,
    and packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts:33-52 pins the two
    tiers separately:

    • overriding a packaged flow (intent: 'override-artifact') — 403 NOT_OVERRIDABLE;
    • creating a brand-new flow (intent: 'runtime-only') — still open, but ENV-WIDE ONLY. An
      org-scoped brand-new flow is refused too, by orgScopedWriteRefusal.

    ⇒ "An org-owned flow" in the literal sense — a sys_metadata flow row carrying organization_id —
    is refused today, and making it work means flipping allowOrgOverride, which turns the identity pin
    in protocol.org-scoped-write-refused.test.ts red and re-opens the #6190 class.
    The ruled direction
    survives only if "org-owned" means a row the org owns operationally, not an org-scoped metadata row.

    F4. Only ONE of the four trigger types carries an organization into the run.

    packages/triggers/trigger-record-change/src/record-change-trigger.ts:453 builds the context with
    ...(session.organizationId ? { tenantId: session.organizationId } : {}), landing on
    AutomationContext.tenantId (packages/spec/src/contracts/automation-service.ts:57).

    The zero, with its positive control:

    POSITIVE CONTROL  trigger-record-change/src/record-change-trigger.ts : 4 matches for tenantId
      trigger-schedule/src/schedule-trigger.ts        (249 lines)  0 matches for tenantId|organizationId
      trigger-schedule/src/time-relative-trigger.ts   (472 lines)  0 matches
      trigger-api/src/api-trigger.ts                  (216 lines)  0 matches
    

    ⇒ A per-org takeover is undecidable at dispatch for scheduled, time-relative and inbound-API
    flows.
    There is no organization in the context to consult a per-org record with. This is the single
    hardest constraint on Gap 1 and it drives Fork A below.

    F5. The deployment's default shape is single — one logical tenant, no org wall.

    packages/spec/src/security/tenancy-posture.ts:12-16, 40: three postures, and single is
    "one logical tenant; factories modeled as business units in one tree" with "none (inert)" as its
    Layer 0 wall. group and isolated "require the enterprise @objectstack/organizations runtime"
    (:26-29).

    ⇒ For the majority deployment shape, "org-level" and "env-level" are the same scope. A per-org
    record buys nothing there and costs a dimension everywhere.

    F6. The published capability doc already PROMISES this, and the platform cannot keep it.

    content/docs/capabilities/integrations.mdx:17, verbatim:

    Marketplace templates — install complete apps (HotCRM among them) with one click: objects,
    views, flows, dashboards, and seed data included, then customize in Studio.

    Measured against F1/F3, "then customize in Studio" is false for flows specifically: the override write
    is refused 403, and the off-switch is a field of the artifact. This is the axis-② pull evidence, and
    it is stronger than the card's own provenance because it is a shipped promise, not a wish.


    2. Gap 1 — the org-level activation record

    2.1 The double-firing mechanism, traced end to end

    The card asks where the packaged flow would ALSO fire. Here is the exact path, read rather than assumed:

    1. Boot pull — packages/services/service-automation/src/plugin.ts:870-880:
      ql.registry.listItems('flow'), then for each this.engine.registerFlow(def.name, def).
    2. Registration — engine.ts:2527 this.flows.set(name, parsed). this.flows is declared
      private flows = new Map<string, FlowParsed>() (engine.ts:1252) — name only.
    3. Binding — registerFlow resolves the start node's { objectName, triggerType, condition }
      (engine.ts:1862-1880) and activateFlowTrigger (engine.ts:1963-2013) calls
      trigger.start(binding, ctx => this.execute(flowName, ctx)).
    4. Dispatch — record-change-trigger.ts:269-283: the ObjectQL hook handler checks
      session.skipTriggers, builds the context, and calls the callback. The hook is registered at
      :296-298 under packageId = 'flow-trigger:' + binding.flowName.
    5. Guard — engine.ts:3168-3183: execute() returns
      { success: false, code: 'FLOW_DISABLED' } when this.flowEnabled.get(flowName) === false.

    So a clone double-fires because steps 1-4 run once per NAME, from a registry the org cannot edit.
    Cloning to opportunity_approval_acme adds a second, independent hook on the same object and the
    same event. Nothing anywhere relates the two.

    2.2 ⚠️ There is a second, sharper double-fire: the SAME-NAME clone silently races

    If the clone keeps the packaged name (which the storage layer permits — the metadata uniqueness key
    is (type, name, organization_id, COALESCE(package_id, '')), protocol.ts:12271), then:

    • the SchemaRegistry stores both, under composite keys pkg:name (registry.ts:1148), and
      registry-cross-package-collision.test.ts:20-40 pins that two packages shipping one bare name
      legitimately coexist;
    • listItems (registry.ts:3394-3414) is Array.from(this.metadata.get(type)?.values() || []) —
      it returns both entries, with no dedup and no precedence;
    • the boot loop then calls registerFlow(def.name, …) for each, and this.flows.set(name, …)
      silently overwrites — the winner is decided by Map insertion order.

    ⇒ A same-name clone does not double-fire; it does something worse. It non-deterministically
    replaces
    the packaged flow with no diagnostic, no receipt, and no way for an admin to see which one
    is armed. Any design that permits a same-name clone must state this and close it.

    2.3 Where the consultation MUST happen

    Three candidate seams. Only one survives.

    Seam Has the org? Covers Verdict
    Inside each trigger's handler (record-change-trigger.ts:277) yes, from ctx.session.organizationId one trigger type per copy ⛔ four copies of one policy; and F4 means three of them have no org to read
    Inside activateFlowTrigger's callback closure (engine.ts:1991) yes, via AutomationContext trigger-fired runs only ⛔ misses POST /:name/trigger, subflow calls, and resume
    Inside execute(), beside the existing FLOW_DISABLED guard (engine.ts:3174-3183) yes — context.tenantId, already on the signature every entry path ⭐ the seam

    execute() already owns this verdict, already receives AutomationContext, and already returns a
    classified refusal (code: 'FLOW_DISABLED', added by #9415 precisely so a transport can read a
    verdict instead of guessing — engine.ts:3176-3182). One predicate, one place.

    ⚠️ The structural cost of that seam, stated rather than discovered later: toggleFlow today does
    two things — it guards in execute() and it unbinds the trigger, and the comment at
    engine.ts:2651-2653 says why: "unbinding avoids firing the trigger (and its event-source
    subscription) at all."
    A per-org decision cannot unbind, because the hook is registered once
    per flow env-wide (step 4 above). So a per-org takeover is necessarily an execute()-time refusal:
    the hook still fires, the context is still built, and the run is refused at entry. That is cheap but
    it is not free, and on a high-frequency record-after-update flow it is a per-write cost the
    env-wide variant does not pay.

    2.4 ⭐ The precedent already contains the answer's SHAPE — and it is row state, not metadata

    This is the most reusable thing I measured. In #11513's permission-set lock, the deactivate action on
    a package-declared set is not refused. packages/plugins/plugin-security/src/permission-set-projection.ts:1128-1145,
    verbatim:

    [#4669] A patch that touches ONLY row state (active — what the activate/deactivate actions send
    as bodyExtra) is not a definition write at all: it goes to the driver untouched […]

    So the precedent's "org-level activation record" is: a boolean column on a DATA row that exists per
    packaged artifact, carved out of the customization lock by name.
    Not an overlay, not a
    sys_metadata row, not a metadata type — a row state column. That is precisely the construction F3
    says a flow needs, because a data row has an ordinary organization_id and an ordinary read path,
    while a sys_metadata flow row with an org has neither.

    But the precedent does not hand it over, because flows have no data-row twin. Positive control and zero:

    POSITIVE CONTROL  *.object.ts files for automation DO exist:
      plugin-approvals/src/sys-approval-{action,approver,delegation,request,token}.object.ts
      service-automation/src/sys-automation-run.object.ts
      service-automation/src/sys-flow-dispatch.object.ts
    ZERO              no sys_flow object anywhere in the repo.
    

    sys_flow_dispatch is not it — it is the #10220 idempotency claim ledger, holding id /
    dispatched_at / created_at only (flow-dispatch-store.ts:24, 66-72).

    ⇒ The activation record has to be built. There is nothing to extend. That is Fork B.

    2.5 One existing off-switch, named for completeness

    listItems filters out items whose owning package is disabled (registry.ts:3405-3411,
    isPackageDisabled at :3419-3423). So PATCH /packages/:id/disable does stop a packaged flow
    from ever registering. Two reasons it is not the answer: it is whole-package, not per-flow; and
    isWritablePackage (packages/metadata-protocol/src/package-writability.ts:41-56) refuses the
    lifecycle route for booted code packages and system/cloud-scoped installed packages — i.e. for
    exactly the marketplace packages this card is about.


    3. Gap 2 — clone reference re-pointing

    3.1 What #11513's clone ACTUALLY does — measured, not generalised

    The dispatch asked for this specifically. packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:115-152:

    And, from packages/plugins/plugin-security/src/packaged-permission-set-lock.ts:9-24, verbatim:

    The clone is an ordinary org-owned set with no upgrade linkage, so upgrades keep flowing to the
    base untouched. No silent overlay row is ever minted again.

    ⛔ Explicitly NOT chartered by that ruling, and deliberately absent here: the ServiceNow-style
    explicit overlay layer (badge / customization list / diff-vs-base / revert / upgrade skip-report).
    Recorded as the mature direction if customer pull for in-place customization ever appears.

    3.2 ⇒ It does NOT generalise to flows. Four measured reasons.

    1. A permission set has no trigger. Cloning one adds a row that grants nothing until assigned;
      cloning a flow adds a live hook (§2.1). The precedent never had to answer the double-fire
      question, so it does not answer it.
    2. A permission set's clone re-points nothing. Its facets are object_permissions,
      field_permissions, system_permissions, row_level_security, tab_permissions — all
      by-name references to packaged objects and fields that the clone deliberately keeps pointing at.
      That is correct there and it is correct for a flow's objectName too. The precedent's silence on
      re-pointing is not a solved problem; it is an absent problem.
    3. A flow has one reference class the precedent has no analogue for: subflow.
      packages/services/service-automation/src/builtin/subflow-node.ts:74, 109 resolves
      config.flowName through engine.execute(flowName, childContext) — i.e. through the same
      name-keyed global map
      from §2.1. So:
      • a clone of flow A keeps calling the packaged subflow B, which is almost always right;
      • but if the org also takes over B, then A's clone still calls B by the packaged name, and if
        the takeover deactivates B the subflow node fails with subflow 'B' failed: Flow 'B' is disabled
        (subflow-node.ts:147 composing execute()'s FLOW_DISABLED from engine.ts:6563).
        ⚠️ Takeover cascades through subflow references, and nothing detects it. A design that answers
        Gap 1 without answering this ships a second silent breakage.
    4. There is no reference index to mechanise re-pointing with. Positive control and zero:
    POSITIVE CONTROL  10 files under metadata-core/ metadata-protocol/ metadata/ contain "dependenc"
    ZERO              referencedBy | referenceIndex | reverseIndex | whereUsed | where_used
                      -> 0 matches across all of packages/
    

    ⇒ Automatic re-pointing is not implementable today at any price this card should pay. Whatever
    is ruled, the clone's references stay pointed at what the original pointed at, and the honest design
    question is only what the surface tells the admin about that (Gap 3).

    3.3 What happens on package upgrade

    Follows from F3 + §3.1: the packaged artifact is replaced in place by its package (it is a code
    artifact — isWritablePackage returns false for booted code packages,
    package-writability.ts:44-48), and the clone, living in a writable base (a project-scoped DB
    package or an ADR-0048 authoring-workspace id, package-writability.ts:50-56), is untouched. So the
    clone silently keeps running the pre-upgrade logic — exactly the precedent's accepted cost
    ("no upgrade linkage"), and exactly what the precedent's own header records as the reason the
    ServiceNow overlay layer is the mature direction if pull appears.

    ⚠️ One flow-specific sharpening the precedent does not carry: a stale permission-set clone grants
    stale permissions, which is inert until someone notices. A stale flow clone keeps acting —
    writing records, sending email, calling HTTP nodes — against objects whose fields the upgrade may
    have changed. The blast radius of "no upgrade linkage" is materially larger for flows, and that is
    an argument the maintainer should weigh, not one I should settle.


    4. Gap 3 — the designer / Setup surface

    4.1 Measured: flows and permission sets do not live in the same app

    • Permission Sets are in Setup — packages/plugins/plugin-security/src/security-plugin.ts:974
      contributes { id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'sys_permission_set' },
      and packages/platform-objects/src/apps/setup-nav.contributions.ts:16, 86 records the contribution.
    • Flows are in Studio only — packages/platform-objects/src/apps/studio.app.ts:234-239:
      { id: 'nav_flows', componentRef: 'metadata:resource', params: { type: 'flow', package: '{active_package}' } }.
      There is no flows entry in Setup.

    And the two apps are deliberately different audiences. studio.app.ts:4-22, verbatim: "the
    developer/maker workbench […] intentionally separate from Setup, which is the operator/admin app […]
    Different audience (implementers/devs vs IT admins) […] Independent permission gating
    (studio.access vs setup.access)."

    ⇒ The ruled requirement is "an org admin customizes post-install", and org admins are Setup's
    audience, not Studio's.
    Today a packaged flow is only reachable through a developer app, behind a
    developer capability. That is a genuine fork (Fork D), not an implementation detail.

    4.2 Measured: there is no flow clone action of any kind

    POSITIVE CONTROL  clone_permission_set found at sys-permission-set.object.ts:115 (+ 2 translation files)
    ZERO              no clone action/route in service-automation/src, runtime/src/domains/automation.ts,
                      or spec/src/automation. The only "clone" hits are jsonClone() in
                      suspended-run-store.ts:71 (a deep-copy helper) and a node copy in
                      bpmn-mapping.ts:283 — read and confirmed unrelated.
    

    4.3 Measured: the precedent's drift surface is NOT clone-vs-base drift

    sys_permission_set carries managed_by, customized, drift_status, drift_detail and a
    "Needs Attention" list view (sys-permission-set.object.ts:180-200). It is tempting to read this as
    the answer to "what does the surface show about drift from the packaged original?" It is not.
    packages/plugins/plugin-security/src/permission-set-drift.ts:11-31 defines drift as exactly two
    mechanisms — overlay_shadow (a sys_metadata overlay row frozen over the artifact) and
    provenance_skip (the row's managed_by is not 'package') — i.e. drift of the ENFORCED copy of
    a package-declared set from its own artifact.
    A clone is a different row with a different name; it
    is not in scope of any drift status, by construction.

    ⇒ On the ruled direction as measured, the honest answer to "what does the surface show about drift"
    is: nothing exists, and the precedent deliberately declined to build it.
    Building it here would be
    adopting the ServiceNow-style layer the 2026-08-24 ruling explicitly left unchartered. That is a
    maintainer call, and it is Fork D's second axis.


    5. The forks

    ⛔ These are presented with recommendations and costs. I am not deciding them.

    FORK A — What is the activation record's SCOPE KEY?

    Option Real cost
    A1 Env-wide. One durable row per packaged flow, no org column. Consulted in execute(); may ALSO unbind the trigger, exactly like toggleFlow does today. Does not deliver "org-level" as literally worded. In group/isolated postures every org shares one switch — which is today's #10243 defect made durable rather than removed.
    A2 Per-org, record-change flows only. Org column on the record; execute() consults it against context.tenantId; a flow whose trigger carries no org (F4) is refused a per-org takeover loudly at the moment the takeover is attempted. Honest but partial: three of four trigger types can only ever get A1 semantics. Two mechanisms to explain, and the split follows trigger type, which is not a distinction an admin has any reason to hold in their head.
    A3 Per-org for all four types, by plumbing an org into the schedule / time-relative / api triggers. Genuinely large. Each of the three has no organization concept today (F4); "which org does a nightly sweep belong to" is a product question, not a plumbing one, and answering it wrong means a scheduled flow fires N times or zero times. This is a card family, not a card.

    Recommendation: A1 now, with the record's schema shaped so A2 is an additive column later.
    Reasoning, on the axes:
    ① 项目长远合理性 — A1 is the durable, org-safe replacement for the flowEnabled map that #10243
    already ruled a defect; it removes the leak whether or not the org dimension ever lands, and it is
    the only option that can also unbind (§2.3), which keeps the fix behaviourally identical to the
    toggleFlow semantics already documented. A3 is right eventually and wrong now.
    ② 实际业务拉动 — F6 is real, published pull for customization; there is no measured pull for
    per-org divergence
    specifically. The card's own motivating case is one admin on one install.
    ③ 防 AI 犯错 — A1 has one rule ("this flow is off in this environment"). A2 ships two rules
    whose boundary is the trigger type, which is precisely the kind of conditional surface an AI author
    mis-generalises; A2 is only safe if the refusal at takeover time is loud and names the trigger type.
    ④ 创业阶段不扩散 — F5 is decisive: single is the default posture and is entitlement-gated away from
    multi-org, so A1 is the complete answer for the shape most installs have, at a fraction of the surface.

    ⚠️ If the maintainer wants "org-level" literally, the answer is A2, not A3, and the ruling
    should be recorded as accepting that scheduled/API packaged flows get an environment switch only.

    FORK B — Where does the record LIVE?

    Option Real cost
    B1 A new sys_flow_activation data object — one row per (flow, scope), columns flow_name, active, replaced_by, package_id, plus the ordinary injected organization_id. Consulted in execute(). A new platform object and its ADR-0087 obligations. Smallest new surface that is still a real, durable, org-safe row. Deliberately not metadata, so F3 cannot bite.
    B2 A full sys_flow data-row twin per flow, mirroring sys_permission_set — active as row state, managed_by, package_id, customized, drift_status. Reuses the precedent wholesale, including a Setup list view and the projection/lock pattern. But it is a second source of truth for every flow, and the precedent's own permission-set-drift.ts:11-31 exists solely because keeping a data row and an artifact in sync produced two independent silent-freeze mechanisms. Importing that machinery to buy a boolean is the trade.
    B3 Reuse sys_metadata with a distinct type (e.g. a flow_activation metadata type, env-wide rows only). Buys the existing publish/audit path, but puts operational state into the metadata plane, where F3's rules apply and where a future org dimension is structurally blocked. ⛔ I would argue against it on the measurement rather than present it as equal.

    Recommendation: B1. ① it is the construction F3 leaves open, and the one the precedent's
    row-state carve-out (§2.4) validates as the correct plane for "is this artifact switched on";
    ② it is the minimum that answers the card; ③ one row, one meaning, no reconciliation loop for an AI
    or a human to misread; ④ B2's cost is not the object, it is the drift reconciler the precedent
    needed — that is a large, measured cost to import for a boolean.

    FORK C — What is the clone NAMED, and where does it land?

    Option Real cost
    C1 New name, writable base, no linkage — the #11513 shape exactly. Clone dialog demands a new machine name; the clone lands in a project-scoped/authoring-workspace package (package-writability.ts:50-56). The org must switch the original off separately (Fork A's record). Two steps, and if the admin does only the first, both flows are live — the measured double-fire. ⇒ the clone action and the takeover must be one transaction, or the surface must refuse to leave the pair in that state.
    C2 Same name in a different package, relying on (type, name, organization_id, package_id) uniqueness. ⛔ Argue against: §2.2 measured that listItems returns both and this.flows keys by bare name, so the winner is Map insertion order — a silent, non-deterministic replacement. This is only viable if the engine's flow map first gains a package coordinate, which is a much larger change than the card.
    C3 New name + a recorded cloned_from pointer (flow_name + package_id + the base's version at clone time), stored on the activation record, used for nothing at runtime. One extra column. It does not create upgrade linkage (the precedent's ruled non-goal), but it is the difference between a surface that can later say "the base moved since you cloned" and one that structurally cannot.

    Recommendation: C1 + C3. ① cloned_from is provenance, not linkage — it does not re-open the
    overlay direction the ruling rejected; ② it is what makes any future drift surface possible without
    a migration; ③ ⭐ the params-list lesson from #11703 transfers directly and is the largest
    implementation risk here
    : a flow definition has far more facets than a permission set's six, so a
    clone assembled from an enumerated field list will silently drop nodes or edges. The flow clone
    must copy the whole parsed definition and mutate only name/label/status, never assemble a
    body from a param list; ④ C3 is one column.

    ⚠️ And the subflow cascade (§3.2 item 3) needs a ruling of its own — see Open Question 2.

    FORK D — Which app owns the surface, and what does it show?

    Option Real cost
    D1 Studio only. Clone + takeover live beside nav_flows (studio.app.ts:234-239), gated studio.access. Cheapest, and it is where flows already are. But it does not meet the ruled requirement's own words — an org admin customizing post-install — since Setup, not Studio, is the admin app (studio.app.ts:4-22).
    D2 A Setup surface for packaged automation — a list of packaged flows with their activation state and the clone/takeover actions, contributed the way nav_permission_sets is (security-plugin.ts:974). Studio keeps the editing. A new Setup nav area and its permissions. This is the option that matches the precedent and the requirement: admins take over in Setup, developers edit in Studio.
    D3 D2 plus a drift surface — drift_status-style columns and a "Needs Attention" view comparing clone to base. ⛔ This is the ServiceNow-style layer the 2026-08-24 permission-set ruling explicitly left unchartered (packaged-permission-set-lock.ts:17-21). Proposing it here would extend that ruling onto a surface nobody has asked for.

    Recommendation: D2, with D3 explicitly deferred and recorded. ① D2 mirrors the precedent's
    division exactly; ② F6's promise is addressed to the person installing a marketplace app, who is a
    Setup user; ③ a Setup surface that shows "packaged: on/off" and "replaced by: (clone)" makes the
    two-step state visible, which is the main defence against the C1 half-done state; ④ D3 is real
    scope with no pull, and the precedent already declined it once.

    ⭐ Minimum honest content for the D2 surface, given §4.3: per packaged flow — is it on for this
    scope
    , is a clone designated as its replacement, and at what base version was the clone
    taken
    (C3). ⛔ Not a diff-vs-base. Saying "no drift information exists" plainly is better than a
    customized flag that the precedent measured going silently wrong.


    6. What this needs from packages/spec — flagged, not touched

    ⛔ This lane has zero ownership of packages/spec. Each item below is a spec-seat card, and
    none of it is work this seat should do.

    1. If Fork B lands as B1 — a new platform object needs no spec edit if it is declared in
      packages/platform-objects like its siblings. ⇒ likely zero spec surface. This is a point in
      B1's favour worth weighing.
    2. ⛔ Nothing here should flip allowOrgOverride on the flow registry row
      (metadata-plugin.zod.ts:827). F3 is why. protocol.org-scoped-write-refused.test.ts pins
      orgOverridable by identity, so such a flip turns it red — and correctly so.
      If any accepted design finds itself needing that flip, that is the signal the design has drifted
      back into org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190, not a pin to update.
    3. A FLOW_DISABLED-adjacent refusal code, if the maintainer wants a takeover-refused run to be
      distinguishable from an ordinary disabled one. FLOW_DISABLED is already a StandardErrorCode
      (ADR-0112) so reusing it mints no ledger entry; a new code would. ⇒ recommend reusing
      FLOW_DISABLED
      and carrying the distinction in the message, which keeps this off the spec seat
      entirely.
    4. FlowBuilderConfig (packages/spec/api-surface/studio.json:24) may need a flag if the
      designer must render a packaged flow read-only with a "Clone to customize" affordance. ⚠️ Whether
      it does is an objectui reading, NOT MEASURED here (§0).

    7. Open questions for the maintainer

    Q1 — Fork A's scope key. Is "org-level" in the ruling meant literally (a per-organization
    switch), or does it mean "the customer's installation, as opposed to the package"? F4 and F5 make
    these very different builds, and the measurement says the second is both cheaper and complete for the
    default posture. My reading of the card, offered as a reading and not a decision: the motivating
    case (one admin, one install, one threshold) is satisfied by A1.

    Q2 — The subflow cascade (§3.2 item 3). When an org takes over flow B, what happens to packaged
    flow A whose subflow node calls B by name? Three answers, all defensible:
    (a) nothing — A keeps calling the packaged B, which the takeover has switched off, so A fails
    loudly at that node; (b) the takeover redirects by name — execute('B') resolves to the
    designated replacement for the consulting scope, which makes takeover transparent to callers but is
    a name-resolution override, uncomfortably close to the overlay read path the ruling forbids;
    (c) refuse the takeover while any packaged flow references B as a subflow, naming them.
    ⚠️ I flag (b) specifically: it is the option that most nearly meets the intuition of "the org runs
    the clone", and it is also the one that could be argued to re-introduce an overlay read path under
    another name. That judgement is the maintainer's, and it should be made explicitly rather than
    arrived at by implementation.

    Q3 — Fork D's ceiling. Is the Setup surface allowed to say anything at all about the base moving
    under a clone (the C3 cloned_from version), or does the 2026-08-24 "no diff-vs-base" disposition
    carry over to flows unchanged? ⚠️ §3.3 is the reason to ask rather than assume it carries: a stale
    permission-set clone grants stale access, while a stale flow clone keeps writing records and calling
    external systems
    . The blast radii differ enough that the earlier ruling's reasoning may not transfer.

    Q4 — Two steps or one. Should "clone" and "take over" be a single atomic admin action (clone,
    designate replacement, deactivate packaged original), or two deliberate steps? One action cannot leave
    the measured double-fire state; two steps let an admin clone for inspection without changing runtime
    behaviour. Recommendation: two steps, but the Setup surface (D2) must show the half-done state as a
    state, not as silence.


    Refs: #11513 (clone-to-customize precedent) · #6190 (phantom overlay) · #11663 (design-first
    pattern) · #10243 (the measured env-wide toggle leak) · #11703 (the params-list silent drop) ·
    ADR-0005 · ADR-0048 §3.4 · ADR-0094 · ADR-0105


    Generated by Claude Code


    Generated by Claude Code

  7. claude commented on Aug 25, 2026

    @claude
    Contributor
    {
      "issue": 11665,
      "status": "needs_decision",
      "branch": null,
      "pr": null,
      "premise_still_valid": true,
      "summary": "DESIGN dispatch, delivered as a design document comment on #11665: https://github.com/objectstack-ai/objectstack/issues/11665#issuecomment-5404974967 (read back and verified intact; the platform appended its own bare footer, my session-URL footer survived). No code, no branch, no PR, no changeset. All three named gaps measured against origin/main @d63b01436035f0866df8c22156e26ab2a064580d. PREMISE CONFIRMED VALID: flow `status` really is a field of the packaged artifact (spec/src/automation/flow.zod.ts:641), and the sibling `active` key was deliberately deleted as inert (flow.zod.ts:664-671) — so there is exactly one switch and a package ships it. SIX GROUND-TRUTH FACTS the document rests on. F2: the only runtime override is `flowEnabled`, a Map keyed by flow NAME alone (engine.ts:1253), and #10243 already ruled it a measured cross-org leak — runtime/src/domains/automation.ts:319-327 verbatim: 'The bit is NOT a row, so no organization wall scopes it … the automation service is ONE instance per environment … a tenant org owner … switched a shipped flow off and an unrelated tenant in a DIFFERENT organization … read it off'. So the takeover record cannot be a refinement of that map. F3 (the wall the design must not hit): an org-scoped `flow` metadata row is a PHANTOM — metadata-core/src/meta-write-org-scope.ts:16-24 names `flow` as a measured specimen ('binds its triggers until the restart, then stops firing'), and protocol.flow-org-override-closed.test.ts:33-52 pins that override-artifact is 403 NOT_OVERRIDABLE while runtime-create stays open ENV-WIDE ONLY. ⇒ 'org-owned flow' can only mean a row the org owns OPERATIONALLY, never an org-scoped sys_metadata row; the load-bearing pin stays untouched and any design needing that flag flipped has drifted back into #6190. F4 (hardest constraint): only the record-change trigger carries an org into the run (record-change-trigger.ts:453 → AutomationContext.tenantId); schedule/time-relative/api carry NONE — so a per-org takeover is UNDECIDABLE at dispatch for 3 of 4 trigger types. F5: `single` is the default posture with an inert wall, and group/isolated are enterprise-entitlement-gated, so for most installs 'org-level' and 'env-level' are the same scope. F6 (the axis-② pull): content/docs/capabilities/integrations.mdx:17 already PUBLICLY PROMISES 'install complete apps … flows … then customize in Studio' — a shipped promise the platform measurably cannot keep for flows. GAP 1: traced the full double-fire path (boot pull plugin.ts:870-880 → registerFlow engine.ts:2527 → activateFlowTrigger engine.ts:1963-2013 → the ObjectQL hook at record-change-trigger.ts:296 → execute()'s FLOW_DISABLED guard engine.ts:3174-3183) and named the consultation seam: inside execute(), beside the existing guard — the only one of three candidates that has the org (context.tenantId) AND covers every entry path (trigger, manual, subflow, resume). Stated its structural cost: a per-org decision CANNOT unbind the trigger the way toggleFlow does (engine.ts:2651-2653), because the hook is registered once per flow env-wide, so it is necessarily a per-write refusal. ⭐ The precedent already contains the answer's SHAPE: in #11513 the deactivate of a PACKAGED permission set is NOT refused — permission-set-projection.ts:1128-1145 carves row state out of the lock, verbatim 'switching a packaged set off is not a customization of it (#4669)'. So the activation record is a boolean COLUMN ON A DATA ROW, not metadata — exactly the plane F3 leaves open. But flows have no data-row twin to extend (positive control: 7 automation *.object.ts files exist; zero sys_flow object — sys_flow_dispatch is only the #10220 claim ledger), so it must be built. GAP 2: measured the #11513 clone path rather than assuming it generalises, and it does NOT, for four reasons — a permission set has no trigger (the precedent never had to answer double-firing); its clone re-points nothing (an absent problem, not a solved one); a flow has `subflow`, which resolves config.flowName through the SAME name-keyed global map (subflow-node.ts:74,109), so a takeover CASCADES — a packaged parent calling a taken-over child fails with 'subflow B failed: Flow B is disabled' and nothing detects it; and automatic re-pointing is not implementable at all today (positive control: 10 files contain 'dependenc' in the metadata layer; ZERO matches for referencedBy|referenceIndex|reverseIndex|whereUsed across all of packages/). On upgrade the clone is untouched and silently keeps running pre-upgrade logic — the precedent's accepted 'no upgrade linkage' cost, but I flagged that the blast radius is materially larger for flows (a stale permission-set clone grants stale access; a stale flow clone keeps WRITING records and calling HTTP nodes). ⭐ Transferable implementation risk: #11703's lesson is that the clone's params list IS the payload and an omission is a SILENT drop — a flow has far more facets than a permission set's six, so a flow clone must copy the whole parsed definition and mutate only name/label/status, never assemble a body from an enumerated field list. GAP 3: flows live in STUDIO only (studio.app.ts:234-239, gated studio.access) while permission sets are in SETUP (security-plugin.ts:974) — and studio.app.ts:4-22 states the split is deliberate ('Different audience (implementers/devs vs IT admins)'). The ruled requirement is an ORG ADMIN customizing post-install, and org admins are Setup's audience, so the surface has no home today. Zero flow clone action exists (positive control: clone_permission_set found; the only automation 'clone' hits are jsonClone() and a BPMN node copy, both read and confirmed unrelated). And the precedent's drift machinery is NOT clone-vs-base drift — permission-set-drift.ts:11-31 defines it as overlay_shadow/provenance_skip only, i.e. drift of the ENFORCED copy from its own artifact; the ServiceNow-style diff-vs-base layer is EXPLICITLY unchartered by the 2026-08-24 ruling (packaged-permission-set-lock.ts:17-21). So the honest answer to 'what does the surface show about drift' is: nothing exists, and building it would extend a ruling nobody asked to extend. FOUR FORKS presented with four-axis passes and recommendations, none decided by me: A (the record's scope key — env-wide vs per-org vs plumbing org into all four triggers), B (where it lives — new sys_flow_activation object vs full sys_flow data-row twin vs sys_metadata), C (clone naming/home — new name vs same name vs new name plus a cloned_from provenance pointer), D (which app owns the surface — Studio only vs Setup+Studio vs adding a drift surface). SPEC OWNERSHIP FLAGGED, NOT TOUCHED: recommended B1, which likely needs ZERO packages/spec surface (a platform object declared in platform-objects like its siblings); recommended reusing the existing StandardErrorCode FLOW_DISABLED rather than minting a new code, which keeps this off the spec seat entirely; and stated ⛔ that nothing here should flip allowOrgOverride on the flow registry row. ⚠️ RELATED EXISTING CARD the PM should see: #7571 ('Package-provided flows are read-only in Studio — no design mode and no node inspector on any node … a product question worth a ruling') overlaps Gap 3 directly and is still open.",
      "tests": "No code produced ⇒ no gates owed. dispatch-gates NOT run and none owed: clean tree, zero commits, no branch, no worktree created (all reads were `git show`/`git grep` pinned to origin/main, which needs no worktree — the shared checkout was never written to). No build, no test run, so the shared verify lock was never taken and no ablation was performed (nothing to restore, so no restore could fail silently). THE DELIVERABLE WAS A STATIC MEASUREMENT, and every asserted zero carries a positive control through the SAME instrument, run in the same invocation: (1) org identity in non-record triggers — CONTROL record-change-trigger.ts = 4 matches for tenantId; MEASURED schedule-trigger.ts (249 lines) / time-relative-trigger.ts (472 lines) / api-trigger.ts (216 lines) = 0 matches each, with the line counts shown so 'empty file' is excluded. (2) no sys_flow object — CONTROL: the same glob finds 7 automation *.object.ts files (5 approvals + sys-automation-run + sys-flow-dispatch); MEASURED: the only flow-named object is sys-flow-dispatch, read and confirmed to be the #10220 claim ledger (id/dispatched_at/created_at only). (3) no reference index — CONTROL: 10 files under metadata-core/metadata-protocol/metadata contain 'dependenc'; MEASURED: referencedBy|referenceIndex|reverseIndex|whereUsed|where_used = 0 across all of packages/. (4) no flow clone — CONTROL: clone_permission_set found at sys-permission-set.object.ts:115 plus 2 translation files; MEASURED: the only 'clone' hits in service-automation/runtime automation domain/spec automation are jsonClone() at suspended-run-store.ts:71 and a node copy at bpmn-mapping.ts:283, both READ and confirmed unrelated rather than counted as hits. (5) no duplicate of the filed finding — searched open issues before filing; 14 results, none matching (#7571 is adjacent and is reported to the PM rather than treated as a duplicate). ⚠️ ONE INSTRUMENT FAILED ITS CONTROL AND WAS DISCARDED, reported rather than hidden: checking my own document for stray non-English prose, `grep -P '\\p{Han}'` returned exit 1 (no matches) on the design doc — but the SAME grep returned 0 matches on AGENTS.md, which certainly contains Chinese. The control failed, so the silence was NOT evidence; I re-ran the check in python (control: AGENTS.md = 19 lines with Han) and it found 6 lines in my document — 4 legitimate axis labels and 2 stray Chinese words in English sentences, which I then fixed. Had I trusted the first instrument the document would have shipped with them. PRE-POST HYGIENE: control-byte self-scan per the discipline — `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the document = clean. POST-POST VERIFICATION: read the comment back via the API. Body is byte-identical to source — the 36136-vs-35822 delta was UTF-8 bytes vs Python characters, NOT sanitizer loss; a unified diff of source against the stored body shows exactly one hunk, an ADDITION (the platform appended its own bare-form footer). My session-URL footer survived, as the discipline predicts.",
      "open_questions": [
        {
          "question": "FORK A — what is the activation record's SCOPE KEY? Is 'org-level' in the ruling meant literally (a per-organization switch), or does it mean 'the customer's installation, as opposed to the package'? This is forced by F4: only the record-change trigger carries an organization into the run; schedule, time-relative and inbound-API triggers carry none, so a per-org takeover is undecidable at dispatch for 3 of 4 trigger types.",
          "options": [
            "A1 env-wide — one durable row per packaged flow, no org column, consulted in execute() and able to unbind the trigger exactly as toggleFlow does. Cost: does not deliver 'org-level' as literally worded; in group/isolated postures every org shares one switch, which is today's #10243 defect made durable rather than removed.",
            "A2 per-org for record-change flows only — org column on the record, matched against context.tenantId, with a per-org takeover of a schedule/api flow refused LOUDLY at the moment it is attempted. Cost: two mechanisms whose boundary is the trigger type, which is not a distinction an admin has any reason to hold in their head.",
            "A3 per-org for all four types, by plumbing an organization into the schedule/time-relative/api triggers. Cost: genuinely large — none of the three has an organization concept today, and 'which org does a nightly sweep belong to' is a product question whose wrong answer makes a scheduled flow fire N times or zero times. A card family, not a card."
          ],
          "recommendation": "A1 now, with the record's schema shaped so A2 is an additive column later. ① it is the durable, org-safe replacement for the flowEnabled map that #10243 already ruled a defect, and the only option that can ALSO unbind, keeping behaviour identical to the documented toggleFlow semantics; ② F6 is real published pull for CUSTOMIZATION, but there is no measured pull for per-org DIVERGENCE — the motivating case is one admin on one install; ③ A1 has one rule, while A2 ships two rules whose boundary is the trigger type, precisely the conditional surface an AI author mis-generalises; ④ F5 is decisive — `single` is the default posture and multi-org is entitlement-gated, so A1 is complete for the shape most installs have. ⚠️ If the maintainer wants 'org-level' literally, the answer is A2 and NOT A3, and the ruling should explicitly record that scheduled and API packaged flows get an environment switch only."
        },
        {
          "question": "FORK B — where does the activation record LIVE? (F3 rules out the metadata plane for anything that ever wants an org dimension.)",
          "options": [
            "B1 a new sys_flow_activation data object — one row per (flow, scope) with flow_name, active, replaced_by, package_id and the ordinary injected organization_id, consulted in execute(). Cost: a new platform object and its ADR-0087 obligations; deliberately NOT metadata, so F3 cannot bite.",
            "B2 a full sys_flow data-row twin per flow, mirroring sys_permission_set (active as row state, managed_by, package_id, customized, drift_status). Cost: reuses the precedent wholesale including a Setup list view, but creates a SECOND source of truth for every flow — and permission-set-drift.ts exists solely because keeping a data row and an artifact in sync produced two independent silent-freeze mechanisms.",
            "B3 reuse sys_metadata with a distinct flow_activation type, env-wide rows only. Cost: buys the existing publish/audit path but puts operational state in the metadata plane, where F3's rules apply and a future org dimension is structurally blocked."
          ],
          "recommendation": "B1. ① it is the construction F3 leaves open and the one the precedent's row-state carve-out validates as the correct plane for 'is this artifact switched on'; ② minimum that answers the card; ③ one row, one meaning, no reconciliation loop for a human or an AI to misread; ④ B2's real cost is not the object but the DRIFT RECONCILER the precedent needed — a large, measured cost to import in order to buy a boolean. Worth weighing in B1's favour: it likely needs ZERO packages/spec surface, since a platform object can be declared in platform-objects like its siblings."
        },
        {
          "question": "FORK C — what is the clone NAMED and where does it land? And, separately, the subflow cascade: when an org takes over flow B, what happens to packaged flow A whose subflow node calls B by name?",
          "options": [
            "C1 new name, writable base, no linkage — the #11513 shape exactly. Cost: the org must switch the original off SEPARATELY, so a half-done pair leaves both live — the measured double-fire. ⇒ clone and takeover must be one transaction, or the surface must refuse to leave the pair in that state.",
            "C2 same name in a different package, relying on the (type,name,organization_id,package_id) uniqueness key. ⛔ Argue against: listItems returns both entries and the engine keys flows by bare name, so the winner is Map insertion order — a silent, non-deterministic replacement (this is the defect filed as #11997).",
            "C3 new name plus a recorded cloned_from pointer (flow_name, package_id, and the base's version at clone time) stored on the activation record and used for nothing at runtime. Cost: one column."
          ],
          "recommendation": "C1 + C3. ① cloned_from is PROVENANCE, not linkage — it does not re-open the overlay direction the ruling rejected; ② it is the difference between a surface that can later say 'the base moved since you cloned' and one that structurally cannot; ③ the #11703 params-list lesson transfers and is the largest implementation risk — a flow clone must copy the whole parsed definition and mutate only name/label/status, never assemble a body from an enumerated field list; ④ one column. ⚠️ THE SUBFLOW CASCADE NEEDS ITS OWN RULING and I did not settle it: (a) do nothing, so a packaged parent fails loudly at the node; (b) the takeover redirects BY NAME, so execute('B') resolves to the designated replacement — which makes takeover transparent to callers but is a name-resolution override, uncomfortably close to the overlay read path the ruling forbids; (c) refuse the takeover while any packaged flow references B as a subflow, naming them. I flag (b) specifically: it most nearly meets the intuition of 'the org runs the clone' AND is the one that could be argued to re-introduce an overlay read path under another name. That judgement is the maintainer's and should be made explicitly rather than arrived at by implementation."
        },
        {
          "question": "FORK D — which app owns the surface, and what may it say about the base moving under a clone? (Section 4.3: the precedent's drift machinery is NOT clone-vs-base drift, and the diff-vs-base layer was explicitly left unchartered on 2026-08-24.)",
          "options": [
            "D1 Studio only — clone and takeover beside nav_flows, gated studio.access. Cost: cheapest and it is where flows already are, but it does not meet the ruled requirement's own words, since Setup and not Studio is the admin app.",
            "D2 a Setup surface for packaged automation — packaged flows with their activation state and the clone/takeover actions, contributed the way nav_permission_sets is; Studio keeps the editing. Cost: a new Setup nav area and its permissions.",
            "D3 D2 plus a drift surface (drift_status-style columns, a 'Needs Attention' view comparing clone to base). ⛔ This is the ServiceNow-style layer the 2026-08-24 permission-set ruling explicitly left unchartered."
          ],
          "recommendation": "D2, with D3 explicitly deferred and recorded. ① mirrors the precedent's Setup/Studio division exactly; ② F6's promise is addressed to the person installing a marketplace app, who is a Setup user; ③ a surface showing 'packaged: on/off' and 'replaced by: (clone)' makes the two-step state visible, which is the main defence against C1's half-done state; ④ D3 is real scope with no measured pull and the precedent declined it once already. ⭐ Minimum honest content: per packaged flow, is it on for this scope, is a clone designated as its replacement, and at what base version was the clone taken (C3) — and NOT a diff-vs-base. ⚠️ But I flag one reason the earlier ruling may not transfer unchanged: a stale permission-set clone grants stale ACCESS, while a stale flow clone keeps WRITING records and calling external systems, so the blast radii differ."
        }
      ],
      "out_of_scope_findings": [
        "filed as #11997: a runtime-authored flow reusing a packaged flow's name silently and non-deterministically REPLACES it — registry.ts:3394 listItems returns both the composite-key and bare-key entries with no dedup, while engine.ts:1252 keys this.flows by bare name, so plugin.ts:870-880's pull registers both under one key and Map insertion order decides the winner. Reachable today (flow is allowRuntimeCreate:true), silent in both directions, and the neighbouring type refuses this by design. Unassigned, no labels, left for PM triage; static read only, runtime confirmation not performed and said so in the card."
      ]
    }

    Generated by Claude Code

  8. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Maintainer discussion outcome — the model goes platform-wide first: ADR chartered, this card waits on it

    Blocked-by: #12049

    Source: maintainer, 2026-08-25, live PM chat, discussing this card's four forks. Operative charter, verbatim: 「要整体评估除了流程还有哪些需要自定义的也有类似的问题,开始写adr」, following 「很多所有的元数据都有类似的个性化需求,要统一考虑。」— the Fork B question generalized: flows are the first instance of a platform-wide customization model, and the model is written down once (ADR, governed draft PR, human merge = acceptance) instead of being re-invented per metadata type. #12049 carries the survey + ADR and is dispatched.

    Directions established in the discussion, recorded with provenance (⛔ tentative until the ADR is merged — the merge is the ruling):

    State: needs-user-decision → pm:blocked in the same write. When #12049 closes (ADR merged), the unlock scan returns this card; the services seat then cuts implementation legs against the accepted model.


    Generated by Claude Code

  9. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Unblocked — the blocker #12049 is closed: ADR-0126 is merged and verified on origin/main (merge commit 28b47a93, file docs/adr/0126-packaged-metadata-customization-model.md). Label swapped pm:blocked → pm:queue. Recorded by PM session session_01KWRU3s15AJz7PGW7a7wdCh.

    For the seat that picks this up — the implementation legs consume ADR-0126 §7 (the packaged-flow worked example), now settled by the merge:


    Generated by Claude Code

  10. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Two maintainer rulings recorded (2026-08-25, live PM chat, session session_01KWRU3s15AJz7PGW7a7wdCh), verbatim and untranslated: 「v17 ,然后创建一个epic issue,跟进相关的开发。」

    1. Release target: v17 line — the ADR-0126 implementation (this card's legs included) ships as a 17.x minor. Carry this on every leg card. The [finding] spec ships a three-layer metadata-customization protocol (MetadataOverlaySchema, field-level change tracking) with zero consumers — published reference docs describe an unimplemented customization architecture #12057 paper-protocol removal is excluded (breaking → v18).
    2. Epic filed: Epic: packaged-metadata customization (ADR-0126) — flows first, v17 line #12150 — tracks the whole ADR-0126 program (L1–L8); this card is its sub-issue and feeds legs L1–L5.

    Generated by Claude Code

  11. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    State: pm:queue → tracking — anti-preemption, on maintainer instruction (2026-08-25, live PM chat: how to keep the #12150 legs from being claimed by other lane PMs). Recorded by session session_01KWRU3s15AJz7PGW7a7wdCh.

    This card's implementation is now fully decomposed into the #12150 legs (L1 #12155 · L2 #12157 · L3 #12158 · L4 #12156 · L5 objectstack-ai/objectui#6301). ⛔ Do not dispatch this card from the queue — picking it up directly would duplicate the legs. It closes when the §7 flow surface works end-to-end, i.e. when L1–L5 land. The design record (comment 5404974967) and the discussion record remain this card's value; the contract the legs consume is ADR-0126.


    Generated by Claude Code

  12. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Closing as completed — the recorded condition on epic #12150 (「closes when the §7 flow surface works end-to-end (L1–L5)」) is now satisfied. The packaged-flow disable + clone surface this card asked for works end-to-end on the v17 line:

    Model contract: ADR-0126 (28b47a93). Remaining program legs (L6 action disable, L7 docs correction) continue on the epic and are not part of this card's condition.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions