Skip to content

Latest commit

 

History

History
430 lines (351 loc) · 23.3 KB

File metadata and controls

430 lines (351 loc) · 23.3 KB

Duly — Agent Instructions

This is an ObjectStack application: business objects, views, automations and security are declared as TypeScript metadata, not hand-written CRUD.

  • Entry point: objectstack.config.ts (defineStack())
  • Spec package: @objectstack/spec (Zod-first schemas and types)
  • Namespace: duly · object prefix: duly_ · app id: ai.objectstack.duly

⛔ Worktree-first — before your FIRST file edit

Several agents work this repo at once. The shared checkout has its HEAD switched and its tree reset under you, silently clobbering uncommitted work. A feature branch on the shared checkout is not enough.

git worktree add ../duly-issue-<n> -b claude/issue-<n>-<slug> main
cd ../duly-issue-<n> && pnpm install

Make every edit there.

⛔ Never git stash

refs/stash lives in the common .git directory, so every worktree shares one LIFO stack. Two agents stashing in their own worktrees push and pop the same stack — your pop restores the other agent's work and reports success. Use instead, all inside your own worktree:

git diff > /tmp/wip.patch && git checkout -- <paths>   # then: git apply /tmp/wip.patch
git commit -am wip                                     # then: git reset --soft HEAD~1

⛔ Claim the issue before you write any code

Assign the issue to yourself and post a claim comment naming your branch, as the first action of the task. Re-read the comments before writing code: an earlier claim from a different session means the issue is taken, whatever the assignee field says.

Verify after every metadata change

Metadata mistakes fail silently at runtime: a dangling widget binding renders an empty chart, a flow left out of its barrel never fires, an unregistered hook never runs. pnpm validate catches most of them — including a bare field reference on every predicate surface but one. Rule 4 below says which one, and why it is the exception.

pnpm validate    # same gates as build, no artifact — the fast inner loop
pnpm typecheck
pnpm test

Never report a metadata change as done until pnpm validate passes.

Naming conventions

Context Convention Example
Config keys (TS props) camelCase maxLength, defaultValue
Machine names (data values) snake_case duly_task, period_key
Metadata type names singular 'view', 'flow'
File names {name}.{type}.ts task.object.ts, duly.app.ts

role is a reserved word in the platform vocabulary — the author-time linter rejects it. Use position (distribution), permission_set (capability), business_unit (hierarchy), or a domain word.

Rules that are not style

  1. Zod first. Types derive from schemas via z.infer<>.

  2. Barrels — and never edit objectstack.config.ts. Every metadata directory is pre-created and already wired into the config, empty ones included. Add your entry to your OWN src/<type>/index.ts named array (dulyFlows, dulyJobs, …); the config is the one file every parallel task would otherwise collide on. A file not in its barrel is dead metadata that type-checks and never runs. The collections are named arrays rather than Object.values(barrel) because on an empty namespace Object.values has nothing to infer from and resolves against the keyed branch of MetadataCollectionInput, which makes name optional and fails the assignment.

  3. Hooks are registered in defineStack({ hooks }), not collected from the objects barrel. An unregistered *.hook.ts never runs.

  4. Predicates are CEL — record.<field>, never bare <field>, on every surface: object validation rules, field requiredWhen / conditional rules, action visible / disabled, sharing rules, hook conditions, and flow node and edge conditions.

    What decides whether the gate catches you is SCOPE, not which surface you are on. An expression is evaluated either with the record bound as the record namespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope). validate judges a bare identifier only in the first.

    Record-scoped surfaces — validate enforces this, and the failure really is null. Object validation rules, field conditional rules, action visible / disabled, sharing rules and hook conditions all bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires. validate catches it before it ships. Measured on @objectstack/cli 17.2.0, mutating duly_task's skip_needs_reason rule to status == "skipped" && … exits 1 with:

    object 'duly_task' · validation 'skip_needs_reason': bare reference status — a formula/validation expression binds the record as the record namespace, not at top level, so status resolves to nothing and the expression silently evaluates to null. Write record.status.

    A misspelt qualified read is caught everywhere, flows included: record.needs_colection in a flow condition gives unknown field `needs_colection` on `duly_assignment` — did you mean `needs_collection`?.

    Flow node and edge conditions are the one exception — no gate at all. They run in flattened scope, where a bare name may genuinely be a flow variable (a loop iterator, a get_record output, an assignment target), so the platform's collectBoundRecordReads deliberately never reads a bare identifier as a record reference. The same predicate written status == "dispatched" in a flow start condition passes validate with exit 0.

    And in a flow the failure is not null either. A bare status there resolves — to the flattened field, or to a same-named flow variable that was seeded first and shadows it. That shadowing case is the subtler bug: the predicate reads correctly and silently means something else. When a name resolves to nothing the engine throws (ADR-0032 §1c: no silent fallback — a non-ok result is a real fault, not a false condition). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces above.

    Filed upstream as objectstack-ai/objectstack#14089. Until it lands, test/flow-predicates.test.ts is a repo-local stopgap covering exactly the unguarded surfaces — flow node and edge conditions, walked over dulyFlows, with dulyJobs walked as a tripwire — and it is written to be deleted when #14089 ships, not maintained.

  5. Never store what you can filter. No is_late, no is_overdue, no is_open. A stored flag needs a writer that runs every midnight; a formula field is virtual and a filter naming one silently matches nothing. Ask status and due_date directly — they are stored and indexed.

    The one exception, and its exact boundary (#52). A value may be stored when it is written once, at the instant it becomes knowable, and never recomputed — a historical fact, not a maintained flag. duly_task carries four: completed_at and completed_late (stamped at completion), visible_from and late_after (due_date + duty.grace_days, stamped at dispatch).

    The test is not "is it derivable" — every one of these is derivable. It is what happens the day nobody writes it:

    maintained flag (is_late) write-once fact (completed_late)
    needs a writer at midnight yes — and lies the day it does not run no
    changes when config changes yes, retroactively and silently no, by design
    wrong answer looks like a stale flag nobody can see is stale nothing: it is what was true then

    So the question to ask of a candidate column is: would a second write ever have to happen? If yes — if it tracks the clock, or has to be refreshed when a related record is edited — it is the banned shape, whatever it is called. If no, and it records what was true at a moment that has passed, it is the same category as completed_at and it may be stored.

    Two obligations come with using this exception, both load-bearing:

    • readonly: true and ONE writer, which is the hook or the planner. A column a caller can set is a column that drifts.
    • Say what the write-once cost is, where the reader will hit it. For late_after: editing a duty's grace_days does not move the deadline on tasks already dispatched. That surprises whoever has just corrected a misconfiguration, so the late view's comment names duly_catalog_sync — the action that already exists to replay duty edits — as the place a recompute would belong. An undocumented denormalisation is the drift this rule is really about.
  6. sharingModel is mandatory and fail-closed. Unset means private, and the publish linter errors on it (ADR-0090 D1/D7). State it deliberately.

  7. A hierarchy scope REQUIRES requires: ['hierarchy-security']. Omitting it is an author-time hard error, not a silent fallback. readScope/writeScope 'own_and_reports' | 'unit' | 'unit_and_below' (ADR-0057) are the hierarchy scopes, resolved by @objectstack/security-enterprise. objectstack.config.ts already declares the capability — you should not need to touch it — and you author the scopes normally. Build no application-level fallback.

    Grant one without the declaration and defineStack refuses to load, in validateHierarchyScopeCapability. It is not the config file being fussy; it is the platform closing the exact hole this rule used to tell you to live with. The platform's own words:

    A stack that uses one MUST declare requires: ['hierarchy-security']; otherwise the open runtime would silently fail closed to owner-only (the metadata would lie, ADR-0049). This makes that an authoring-time error instead.

    Because the check runs inside defineStack(), an undeclared scope takes validate, build and every test that imports the config — so the symptom is the whole suite going red at once, not one assertion.

    Declaring it does NOT fail an open-edition boot. Measured on this checkout with @objectstack/security-enterprise not installed: validate, typecheck, test and build all exit 0, the kernel logs Bootstrap complete, and validate prints exactly one warning naming the package that provides the capability. That warning is the expected state of this repo — do not silence it. The only two ways to make it go away are installing the enterprise package (deliberately not done here) and deleting the declaration (which puts the hard error back).

    In this open-edition checkout the scopes then resolve to owner-only, so a manager view shows you only your own rows. That is the edition, not a bug to chase; verifying manager visibility for real needs an enterprise runtime and a populated business-unit tree.

    ⛔ org is not a hierarchy scope and passes the check with no declaration at all. That is the trapdoor: it is the nearest thing to hand when a scope will not load and you want "some visibility for managers", and it discloses every row in the tenant. Depth for managers is unit_and_below. Narrower than intended is visible and fixable; org is neither.

  8. English is the source language. Every authored label gets an en entry; zh-CN is hand-translated. Do not hard-code display text in a hook or flow.

  9. Metadata first — a handler is the last resort, not the first. This is an ObjectStack application. Objects, views, flows, jobs, datasets, permission sets and actions are the primary tools; a hand-written handler is what you reach for when none of them can express the thing. Before writing a handler, check whether the platform already has a declarative way to do it. The spec is on disk — @objectstack/spec ships its Zod sources — and the answer is usually a key you have not read yet. If the platform genuinely cannot express it, file an issue against objectstack-ai/objectstack and say so on the card. Do not quietly write around the gap. A workaround in application code is how a platform gap becomes permanent and invisible: it works, nobody upstream ever hears about it, and the next application writes the same workaround from scratch. test/flow-predicates.test.ts is what a declared workaround looks like — labelled a stopgap, pointing at objectstack#14089, and built to be deleted.

Who can customize a view — measured, do not re-derive

No Duly permission set can. Saving a view customization overlay is gated on the platform capability manage_metadata, which src/security/permission-sets.ts grants to nobody — not duly_member, not duly_manager, not duly_admin. The overlay a column-header click persists (PUT /api/v1/meta/view/<name>, stored in sys_metadata as scope: platform, owner: null, org-scoped, replacing the declared view for the whole organisation) is reachable only by a platform admin — isPlatformAdmin: true, which on a dev box is the pnpm demo account and in a deployment is whoever holds admin_full_access.

Measured on @objectstack/rest 17.2.0 against a live pnpm demo, with three self-registered accounts each bound to exactly one Duly set through sys_user_permission_set (binding verified live by A/B: a bound holder reads duly_task 200, an unbound account 403):

Caller PUT /api/v1/meta/view/duly_task.default
anonymous 401 UNAUTHENTICATED
duly_member 403 FORBIDDEN — requires the `manage_metadata` capability
duly_manager 403 FORBIDDEN — same
duly_admin 403 FORBIDDEN — same
platform admin 200 — Saved customization overlay (org=…)

sys_metadata stayed at total: 0 across every refused attempt. The compound twin door (PUT /api/v1/meta/duly_task/views/default) and the reset door (DELETE) carry the identical gate — checked, because a gate on one door and not its twin is the usual bypass. The one metadata-ish store a member can write is sys_user_preference, which requires user_id and is per-person by construction; the org-wide sys_view_definition answers 403.

⛔ Do not "harden" this by denying manage_metadata in src/security/permission-sets.ts. systemPermissions is an additive list with no deny form, so the entry would not be enforcement — it would be a declared-and-unenforced key of exactly the kind ADR-0049 exists to remove, sitting in the one file whose credibility depends on every line in it being live.

What remains true and is not a Duly bug: for a caller who does hold the capability, an ordinary sort click still persists an org-wide overlay carrying the entire view definition, with no save gesture and no UI indication — so an administrator demoing the app can freeze duly_task.default at the shipped definition without knowing it. That is a platform affordance question for objectstack-ai/objectui, deliberately left unfiled here; see #84 for the measurement a report would need.

Landing your work

Branch claude/issue-<n>-<slug> off main, in your own worktree. Push the branch and open a draft PR referencing the issue. All four gates must be green in the PR before you hand it back:

pnpm validate && pnpm typecheck && pnpm test && pnpm build

How to write history — seeds, imports and fixtures

A duly_task cannot be created in done by an ordinary caller. completed_at is readonly, the beforeInsert hook stamps only last_update_at, so completed_at_required_when_done refuses the row:

ValidationError: A completed task must carry a completion timestamp.   (code: VALIDATION_FAILED)

That is correct and it stays. Tasks are dispatched open; completion is a later transition. The refusal is the product working.

Historical rows — a seed, an import, a fixture — are written from a system context instead. { context: { isSystem: true } } is the whole mechanism: it exempts a write from the readonly strip. It is the same leg dispatch.job.ts already uses, and the leg the platform's own seed loader uses (SeedLoaderService.SEED_OPTIONS = { isSystem: true, skipTriggers: true, seedReplay: true }).

It takes two passes, and the second one is the half that gets forgotten.

Column How you seed it
completed_at carried on the insert, from a system context
last_update_at a second pass in mode: 'update' — an insert can never carry it

Why last_update_at is different: beforeInsert stamps it unconditionally, and lifecycle hooks do run on the seed path — skipTriggers suppresses record-change automation, not hooks — so a system insert's value is overwritten with the boot clock. The beforeUpdate leg deliberately does not stamp on an administrative write, so a second write carrying only last_update_at lands untouched. Ergonomics card: #63.

Skip that second pass and there are no stalled rows — open tasks last touched 14+ days ago — so the "Not moving" view is empty on a freshly seeded demo. It is the one signal the product claims is its most valuable, and nothing errors: the seed reports success and the view is simply blank.

The worked example

Three datasets, in this order, in defineStack({ data }):

// 1. sys_user FIRST. duly_task.owner is a user lookup and the seed loader
//    resolves it as a NATURAL KEY against sys_user.name. A bare id that matches
//    no sys_user row does not resolve, and because owner is required the whole
//    task row is refused — "Owner is required" — with the loader also logging
//    the unresolved reference. Measured: without this, nothing seeds.
{ object: 'sys_user', externalId: 'name', mode: 'insert',
  records: [{ name: 'seed_user_alice', username: 'seed_user_alice', /* … */ }] },

// 2. The history itself. completed_at rides along on the insert.
{ object: 'duly_task', externalId: 'subject', mode: 'insert',
  records: [{ subject: '…', owner: 'seed_user_alice', source: 'catalog',
              status: 'done', completed_at: '2026-05-04T09:00:00.000Z' }] },

// 3. The SAME rows again, matched on externalId, to backdate the clock.
{ object: 'duly_task', externalId: 'subject', mode: 'update',
  records: [{ subject: '…', last_update_at: '2026-07-18T08:00:00.000Z' }] },

Prefer relative instants (Date.now() - 45 * DAY) over literals for anything whose age is the point: a hard-coded date stops being "stalled" as the repo ages.

Both directions are pinned by test/seed-history.test.ts — the system write succeeds and an ordinary caller's identical write is still refused. Keep both halves. A test that only proved "the seed may" would quietly turn readonly: true into decoration.

One caveat about which layer refuses

The insert-path readonly strip is a boundary guard: it lives in MetadataProtocolService.createData, which is what @objectstack/rest — and so REST, OpenAPI and MCP — writes through. engine.insert applies no readonly strip at all (the update path is different: that strip is inside ObjectQL and is isSystem-gated). So a direct data.insert from in-process code can set a readonly column at insert with no context and no warning.

Filed upstream as objectstack-ai/objectstack#14147, and pinned as a tripwire in test/seed-history.test.ts so it goes red when the platform closes it. Practical consequence for authoring here: do not read an engine-level test as proof that a column is protected — assert against protocol.createData when the refusal is the thing you care about. Flows are unaffected today because assignment.flow.ts declares runAs: 'system', which is elevated regardless.

isSystem is a key to history, not a key to other people's columns

{ context: { isSystem: true } } exempts a write from the readonly strip. That is what makes the section above work, and it is the whole mechanism — which means it exempts every readonly column, not just the ones this app owns. So it is a licence to write history, and it is not a licence to write a column another component maintains.

readonly: true marks two different things, and only one of them fights back.

What the flag means Example How you seed it
A component owns this column and recomputes it. There is a source table, and a hook derives this value from it. sys_user.primary_business_unit_id — plugin-sharing recomputes it from sys_business_unit_member.is_primary (ADR-0057 addendum D12) Write the source. Seed the rows the projection is computed from and let the platform derive the column.
Nothing recomputes it; its maintenance is just somebody else's surface. No hook, no source table — the flag keeps it off the ordinary edit form. sys_user.manager_id — readonly because org-structure maintenance is its own admin surface (ADR-0092); completed_at, for that matter Write it directly, from a system context, exactly as above. There is nothing else to write.

The first kind fails in a way no gate catches, because it does not fail at write time at all. A direct write lands, reads back correct, and survives every boot for as long as the source table stays empty — the recompute has simply never had an input. The day anything writes one source row for that record, the hook fires and replaces your value with whatever the source says, or clears it. Nothing errors. Measured on #74: twelve users carried a hand-written primary_business_unit_id and sys_business_unit_member had 0 rows, for as long as the seed had existed.

How to tell which kind you are looking at, before you write it:

  1. Read the column's description in @objectstack/platform-objects. The first kind says so — "a denormalised projection of …, maintained by …. Do not edit directly; set it via …". Take that sentence literally; it is not style.
  2. Grep the platform for a writer: grep -rn "<column>\s*:" packages/plugins in the monorepo. The first kind has one (an engine.update in the plugin that owns it) and a hook that calls it. The second kind has only reads.
  3. If it has a writer, find what that writer reads from. That table is what your seed writes.

⛔ Do not generalise from one column to its neighbours. manager_id and primary_business_unit_id sit next to each other in the same Organization field group with the same readonly: true, and they are opposite cases. "Both are readonly, so treat both the same" is precisely the inference that produced the defect.

Product invariants — do not "improve" these away

These are the product, not preferences. If a task seems to require breaking one, stop and raise it on the issue instead of working around it.

  • One task has exactly one owner.
  • Dispatch is idempotent on (duty, owner, period_key).
  • Standing duties never generate tasks.
  • Managers do not enter status. Assigning is their only write.
  • Completion never requires evidence, a note, or a percentage.
  • duly_log_entry never enters a metric, a ranking, or a comparison.
  • Item counts are never ranked or compared, anywhere in the UI.