Skip to content

feat(operations): add a put operation, and fix the target-database authorization mismatch - #2347

Open
dawsontoth wants to merge 11 commits into
mainfrom
security/ops-api-default-database-authz
Open

feat(operations): add a put operation, and fix the target-database authorization mismatch#2347
dawsontoth wants to merge 11 commits into
mainfrom
security/ops-api-default-database-authz

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Two changes on one branch, reviewable in order: a put operation for the operations API, and an authorization fix found while building it.

commit what
1–4 superseded by 5 — kept for review history, squash on merge
5 put: create-or-replace, replacing the earlier full_record flag and __unset__ directive
security: resolve the target database identically in authorization and handlers

put

{ "operation": "put", "database": "dev", "table": "dog",
  "records": [{ "id": 1, "dog_name": "Penny" }] }

The stored record becomes exactly that, so an attribute the request omits is removed. Creates a missing record, replaces an existing one.

Why it's needed. There is currently no way to remove an attribute from a record through the operations API. update and upsert both land on Table.patch for a record that already exists (ResourceBridge.upsertRecords), which merges: an omitted attribute keeps its stored value, and null stores a null. A full replace existed only over REST (PUT /Table/idresource.put), which a client speaking the operations API can't reach — and can't reach at all through a proxied operations endpoint. Reported downstream as HarperFast/studio#1643, where Studio's row editor silently dropped a property deletion.

The workaround available today is delete then insert, which leaves the record absent between two writes (concurrent reads, relationship resolutions from the far side, and replicas all observe the gap), resets __createdtime__, and shows subscribers a delete followed by an insert instead of one write. put is Table.put — the same write REST performs, so the same audit type, replication shape, and retained created time.

update/upsert are deliberately untouched. 5da23c3ef originally used Table.put and hand-copied missing properties to emulate a merge; 5bd363946 replaced that with a real Table.patch, and b302a16e7 added the put fallback for a missing record. Merge semantics is load-bearing v4 compatibility, so this is a new operation rather than changed behaviour.

Two details worth review

The internal marker is an argument, not a request field. putRecords calls upsertRecords(obj, true). The first draft read full_record off the request, which let a client send full_record: true with an update and get a replace — making the operation name stop describing the write, and bypassing the attribute-scoped denial below, which is keyed on the operation. A test pins it; restoring the request read fails that test and nothing else.

A role with attribute_permissions on the table may not use put. Attribute permissions are checked against the attributes a request supplies (checkAttributePerms), which is enough for a merge but not for a replace, where an omitted attribute is removed — so such a role could otherwise erase an attribute it has no update permission for by leaving it out. REST closes the same gap in Table.allowUpdate by restoring those attributes from the stored record, which needs the stored record; authorization runs before anything is read, so it denies instead. Roles that scope no attribute are unaffected. Happy to swap this for a restore-based approach if you'd rather it mirror REST exactly.

Scope reduction

This replaces an earlier design (a full_record flag on update/upsert, plus a per-record __unset__ directive), per review. Three problems retire with it, none now reachable:

  • __unset__ degraded a merge to a whole-record last-writer-wins put under contention — reachable single-node via the optimistic-lock retry, and unrecoverable in replication because the audit carried only the resolved put, not the deletion intent.
  • The reserved __unset__ key reinterpreted a pre-existing user attribute of that name, with no migration.
  • The directive interacted with dynamic attribute expansion, so a rejected write could still have registered attributes on an open table.

__unset__ is not abandoned — #2350 is now the feature request for doing it properly, with removal preserved as intent at the patch layer rather than resolved in the bridge. It's a genuinely better tool than put for "drop one field, don't resend the rest"; it just needs a reverse that carries the prior value, a defined removal-versus-set resolution, and a namespace that can't collide.


The authorization fix

Found while testing the attribute-permission denial; unrelated to the feature.

verifyPerms resolved the target database as requestJson.schema ?? requestJson.database. The handlers resolve it in commonUtils.transformReq, which runs after authorization. The two disagreed three ways, each letting a request be authorized against one target and executed against another.

Impact: any authenticated non-empty role had unconditional read/write/delete on the default data database, and could direct a write into any other named database. Not privilege widening — a role scoped exclusively to a second database, with zero permissions on data, read data.Dog in full (including an attribute it was explicitly denied), updated it, and deleted the record.

1. Neither key present. operationSchema was undefined, so schemaTableMap stayed empty; hasPermissions iterates that map, so it authorized by vacuous truth, and attribute permissions went unchecked too. Verified with a role holding update: false on data.Dog:

{"operation":"update","table":"Dog","records":[…]}                   → 200, record written
{"operation":"update","database":"data","table":"Dog","records":[…]} → 403 required_table_permissions:["update"]

Affected operations, all verified by running them: insert/update/upsert (write), delete, csv_data_load (async write — jobs authorize only at submit, so it rides into the worker), create_attribute (schema mutation), and all three search_* (read). describe_* and every requires_su operation fail closed. search_by_id with get_attributes:['*'] 500s rather than leaking.

2. database: 0, or any falsy-but-present value. ?? kept the 0, which is not a database, so the map stayed empty as in (1) — while transformReq, which tests falsy, defaulted to data and wrote there. Joi.number() is an accepted type, so 0 arrives validated.

3. Opposite precedence. verifyPerms preferred schema; transformReq prefers database. So {"schema":"data","database":"elsewhere"} was authorized against data and written to elsewherethe worst of the three, since it reaches any named database.

Fix

One resolver, commonUtils.resolveTargetDatabase, which transformReq delegates to, so the two cannot drift again. Falsy rather than nullish, database over schema — matching what the handlers have always done, so no handler behaviour changes. transformReq writes only when the value would change: an unconditional assignment throws on a frozen object under strict mode.

Plus a fail-closed backstop in verifyPerms: a named table with an empty schemaTableMap is denied rather than authorized. Both that guard and the map population test != undefined, because hdbTable accepts a number and a truthy test dropped a table named 0 — the same vacuous-truth shape one guard over. getAttributePermissions too.

Also here, from review: the bulk-load attribute-permission opt-out is keyed on the operation being a known bulk load, never on a caller-supplied action. action is an unknown-but-accepted key on this validator, so inferring the opt-out from its presence let any direct request skip every attribute check by attaching it.

Affected versions

4.2.0 through 5.2.6 (current). transformReq's default arrived in 4.2.0 (5d5e58fc3); 4.1 declared schema required. The vacuous-map behaviour dates to ~4.0.8 (76ce6a2e3) but was inert until that default existed. release_4.7.19 has the identical expression at operation_authorization.js:350.

Not affected: REST, MQTT/WebSocket and GraphQL bind the database on the resource class at path-match time and never take it from the request body. The SQL path is the same bug class but a separate, already-fixed code path — and its fix is the stronger one, carrying the same per-reference backstop.

Regression risk

In-repo: nil. All 77 schema-less table-naming requests in integrationTests run as super_user, which returns before the target is used; all 491 non-SU requests already carry schema or database.

External: real, and release-note-worthy. A deployment whose clients send schema-less operations under a restricted role will start getting 403s. That is the vulnerability being closed, but it will look like a breaking change to anyone unknowingly relying on it.

Two intentional behaviour changes beyond the denials: structure_user: ['data'] + create_table with no database goes deny → allow (correct, previously untested), and search_by_id with get_attributes:['*'] and no database goes 500 → a proper allow/deny.


Testing

integrationTests/database/put-operation.test.ts — 18 probes, all passing on rocksdb.

put: the removal, update and upsert still merging (the controls), an explicit null staying a stored null, __createdtime__ surviving and __updatedtime__ re-stamping, create-and-replace, put_hashes reporting, parity with REST PUT (same stored attribute set through both doors), a relationship foreign key still resolving from the far side, a client-supplied full_record not turning an update into a replace, and the attribute-permission denial with its scope (the same role can still merge and insert).

Authorization: all three resolution divergences, asserted in both directions — a denial alone doesn't prove resolution, because the backstop also denies an unresolved target, so the role that does hold rights on the default database must still be allowed and its write must land there. A numeric table: 0. A caller-supplied action not skipping the attribute check.

Also pinned: on a legacy open table the operations-API attribute projection reports every registered attribute, filling in null for one the record doesn't have — so an absent attribute reads back as key: null, identically to one that was removed. A read-path artifact, not storage (verified by inserting a record that never had it), predating this work, and worth a test because it makes a successful removal look like a failed one.

Every guard mutation-checked — reverting it has to turn a test red:

mutation result
put ignores the full-record marker 5 fail
marker read off the request (client-forgeable) 1 fail
attribute-scoped put denial dropped 1 fail
?? instead of || in the resolver 1 fail
schema before database 1 fail
no default at all most fail
numeric-table guards reverted to truthy 1 fail
action-keyed bulk opt-out restored 1 fail

Also green: integrationTests/security/*.test.ts, apiTests/terminology.test.mjs (which deliberately exercises schema-less operations and asserts the data. default), database/{auto-fields,patch-disjoint-field-merge} — 143 with the put suite — plus 575 in northwind, which exercises the CSV and bulk-load paths this touches.

Cross-model review

Two passes via prepush-review.mjs, reviewers=codex+harper-domain, independent=true. Gemini failed on quota both times and both Cursor legs were skipped (their git fetch hits a local SSH-agent failure), so coverage is codex + domain only. Both passes predate this scope reduction — they reviewed the full_record + __unset__ design, and their findings are what drove it. Their still-live findings are fixed; the rest retired with the directive.

Not done

  • Replication is untested here. put replicates as a put, the same as REST PUT already does, but there's no multi-node probe in this suite.
  • LMDB run not done (HARPER_STORAGE_ENGINE=lmdb). The suite is written to run under it.
  • Unit suites not run — a local Harper instance held the RocksDB lock throughout. Everything here is integration-covered; resolveTargetDatabase has no direct unit test.
  • Docs not written. reference/operations-api/operations.md in the documentation repo needs a put entry, plus a note about the open-table read projection. Happy to open that PR once this shape is agreed.
  • The put denial for attribute-scoped roles is blunt — it refuses rather than restoring unwritable attributes from the stored record the way REST does.
  • Whether the security commit warrants a GHSA advisory, a CVE, and backports to 4.7/5.x is a maintainer call; the affected range above is what those would need.

🤖 Generated with Claude Code

dawsontoth and others added 2 commits August 26, 2026 10:54
`update` and `upsert` both merge: for a record that already exists they land on
`Table.patch` (ResourceBridge.upsertRecords), so an attribute left out of the
payload keeps its stored value and `null` stores a null. There was no way to
REMOVE an attribute through the operations API — a full replace existed only
over REST (`PUT /Table/id` -> `resource.put`), which a client talking to the
operations API can't reach. Clients worked around it by deleting the record and
inserting it again; that leaves the record absent between two writes, resets
`__createdtime__`, and shows subscribers a delete followed by an insert.

`full_record: true` selects `Table.put` instead. It is orthogonal to each
operation's create rule, so one flag covers both useful shapes:

  update + full_record -> full replace, still skips a record that isn't there
  upsert + full_record -> full replace, still creates one (= REST PUT)

It has no effect on `insert`, which never writes over an existing record. The
merge default is untouched: that is the v4-compatible behaviour existing clients
depend on, and 5bd3639 / b302a16 made it deliberate.

`.strict()` on the validator key, not the whole object: `validateBySchema` allows
unknown keys and discards Joi's coerced value, so without it a string `"false"`
would validate and reach the bridge still a string, letting truthiness decide a
destructive question.

A role with `attribute_permissions` on the table is refused. Attribute
permissions are checked against the attributes a request SUPPLIES, which is
enough for a merge but not for a replace, where an omitted attribute is removed
— such a role could otherwise erase an attribute it has no `update` permission
for by leaving it out. REST closes the same gap in `Table.allowUpdate` by
restoring those attributes from the stored record, which needs the stored record;
authorization runs before anything is read, so it denies instead.

Covered by integrationTests/database/full-record-write.test.ts: the removal, the
merge default, an explicit null staying a stored null, `__createdtime__`
surviving, both create rules, parity with REST PUT, a relationship foreign key
still resolving, the attribute-permission denial, and the non-boolean rejection.

Refs: HarperFast/studio#1643

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`full_record` can drop an attribute, but only by replacing the whole record — so
a caller who wants to remove one field has to resend every other field, and any
field it doesn't resend is silently dropped too. That makes it the wrong tool for
"delete this one property": it forces a read-modify-write on the client, which
races every other writer.

`__unset__` is the narrower one. It names attributes to remove and merges
everything else as usual:

  { "operation": "update", "database": "dev", "table": "dog",
    "records": [{ "id": 1, "__unset__": ["age"] }] }

`age` is removed; every attribute the request didn't mention keeps its value.

An array of names rather than a map with ignored values: the values would carry
no meaning, and a shape that accepts anything invites callers to read
significance into it.

Resolved server-side in `upsertRecords`, against the record the write transaction
has already loaded, and written as a full replace. That is the point of doing it
here rather than in the client: the merge and the write are one transaction.

Reserved like `__createdtime__`/`__updatedtime__` (`terms.UNSET_ATTRIBUTES`), and
never stored. Both copies of the attribute-collection loop skip it —
`insertUpdateValidate.js` and its documented async twin in `insert.ts` — because
`upsertRecords` feeds that list to `Table.addAttributes`, so on an open
(non-schemaDefined) table an unrecognised `__unset__` would be registered as a
real table attribute.

Refused: the primary key (in the bridge, which is the only layer that knows which
attribute that is — `_writeUpdate` re-asserts it on a full update, so unsetting it
would otherwise be silently ignored), the system timestamps, and any malformed
value.

Attribute permissions are checked precisely, unlike the blanket `full_record`
denial: `getRecordAttributes` contributes the names `__unset__` REMOVES instead of
the key itself, so removing an attribute needs the same `update` permission that
writing it would. A role may unset what it can write and nothing else.

Covered by 11 further probes in
integrationTests/database/full-record-write.test.ts, including the open-table
attribute-registration guard and the permission check.

One behaviour found while testing and pinned rather than changed: on a legacy open
table the operations-API attribute projection reports every REGISTERED attribute,
filling in `null` for one the record does not have — so a removed attribute still
reads back as `key: null` through `get_attributes`, identically to a record that
never had it. `SELECT *` reflects the stored record. This is a read-path artifact,
not storage, and it applies equally to `full_record`; clients removing attributes
from open tables need to know it.

Refs: HarperFast/studio#1643

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for removing attributes over the operations API via the full_record: true flag (for full replaces) and the __unset__ directive (for merging while removing specific attributes). It also unifies target database resolution to resolve authorization bypass vulnerabilities and adds validation and permission checks for these new features. Feedback on the changes suggests making the takeUnsetAttributes helper function in ResourceBridge.ts self-safeguarding by adding defensive checks to verify that the unset parameter is a valid array before processing it.

Comment thread dataLayer/harperBridge/ResourceBridge.ts Outdated
dawsontoth and others added 2 commits August 26, 2026 11:56
…ey apply to

`verifyPerms` runs for every operation, and both new directives pass validation on
requests where they mean nothing — `full_record` is an accepted key on the shared
insert/update/upsert validator, and unknown keys pass validation everywhere. So
neither check may test the directive alone:

- The `full_record` denial fired for ANY operation whose body carried the flag.
  An attribute-scoped role doing an `insert`, a `search_by_*`, or anything else
  with a stray `full_record: true` got a spurious 403 — plausible for a generic
  client wrapper that sets the flag on every write. Those operations never reach
  `Table.patch`, so there is nothing there for the check to protect. Now gated on
  `update`/`upsert`, which is what the flag actually changes, and what the PR
  already claimed ("no effect on insert").

- `getRecordAttributes` contributed the names `__unset__` removes for every
  operation. On an `insert` the directive removes nothing (`upsertRecords` strips
  it, and an insert never writes over an existing record), so demanding `insert`
  permission on attributes the request isn't touching denied that too.

Caught in review by claude[bot] on #2346 for the first; the second is the same
defect one file over, found by looking for it.

Both directions covered: the existing probes still assert the denials fire on
`update`, and three new ones assert an `insert` carrying either directive and a
search carrying a stray flag are NOT denied for an attribute-scoped role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on and handlers

`verifyPerms` resolved the database an operation targets as
`requestJson.schema ?? requestJson.database`. The handlers resolve it in
`commonUtils.transformReq`, which runs AFTER authorization. The two disagreed in
three ways, each of which let a request be authorized against one target and
executed against another:

1. Neither key present. `operationSchema` was undefined, so `schemaTableMap`
   stayed empty; `hasPermissions` iterates that map, so it authorized by vacuous
   truth, and `getAttributePermissions` returned an empty map so attribute
   permissions went unchecked too. `transformReq` meanwhile defaults to the
   default database, and the handler wrote there. Any non-empty role therefore had
   unconditional read/write/delete on `data`: verified for insert/update/upsert,
   delete, csv_data_load, create_attribute, and all three search_* operations. A
   role scoped exclusively to another database read, updated and deleted records in
   `data`.
2. `database: 0` (or any falsy-but-present value). `??` kept the `0`, which is not
   a database, so the map stayed empty as in (1) — while `transformReq`, which
   tests falsy, defaulted to `data` and wrote there. `Joi.number()` is an accepted
   type for the field, so `0` arrives validated.
3. Opposite precedence. `verifyPerms` preferred `schema`; `transformReq` prefers
   `database`. So `{schema:'data', database:'elsewhere'}` was authorized against
   `data` and written to `elsewhere`. This one reaches any named database, not just
   the default.

Fix: one resolver, `commonUtils.resolveTargetDatabase`, which `transformReq` now
delegates to, so authorization and the handlers cannot drift again. Falsy rather
than nullish, and `database` over `schema`, matching what the handlers have always
done.

Plus a fail-closed backstop in `verifyPerms`: a named table with an empty
`schemaTableMap` is denied rather than authorized. That is the shape of this whole
bug class, and of the SQL path's GHSA-5c29-q62v-jrwf, whose fix carries the same
guard. With the shared resolver it is unreachable by construction, so no test
covers it; it is there so a future change to target resolution fails safe.

Affected 4.2.0 through 5.2.6. `transformReq`'s default arrived in 4.2.0
(5d5e58f); 4.1 declared `schema` required, so earlier versions are not
exploitable. The vacuous-map behaviour itself dates to ~4.0.8 (76ce6a2) but was
inert until that default existed. 5da23c3 only carried the expression into the
5.x file.

Not affected: REST, MQTT/WebSocket and GraphQL bind the database on the resource
class at path-match time and never take it from the request body. The SQL path is
the same bug class but a separate, already-fixed code path.

Regression coverage in integrationTests/database/full-record-write.test.ts asserts
all three cases, and asserts them in both directions — a denial alone does not
prove resolution, since the backstop also denies an unresolved target, so the role
that DOES hold rights on the default database must still be allowed and its write
must land there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the security/ops-api-default-database-authz branch from 50a9974 to 7923bd8 Compare August 26, 2026 15:57
@dawsontoth dawsontoth changed the title fix(security): resolve the target database identically in authorization and handlers Remove attributes over the operations API (full_record, __unset__), and fix the target-database authorization mismatch Aug 26, 2026
@kriszyp
kriszyp self-requested a review August 26, 2026 16:12
Raised by gemini-code-assist on #2347: `unset` was cast to `string[]` and queried
with `.includes()` without checking that it is one.

Not reachable today — `validation/insertValidator.ts` rejects a malformed
`__unset__`, and every current route into `upsertRecords` goes through
`dataLayer/insert.ts` create/update/upsertRecords, including the replication
catchup path. But the bridge's own `insertUpdateValidate` does not check this key,
so the helper's safety lived entirely in a different layer, across a module
boundary an internal caller could reach directly. If one did: a non-iterable
reaching the `for…of` in `upsertRecords` throws and takes the write down (the shape
of #2194), and a bare string iterates per character, deleting
single-letter attributes.

Ignored with a warning rather than thrown, because this is the write and
replication apply path where throwing aborts the commit and can wedge a
subscription. Skipping the directive degrades to a plain merge, which removes
nothing — the safe direction, and the same choice `resources/tracked.ts` makes for
an unrecognized CRDT operation.

Departs from the suggested patch in one way: the key is deleted BEFORE the shape is
judged, and on every exit. Returning early on a nullish value, as suggested, leaves
`__unset__` on a record that is about to be written and stores the directive as
data. Membership is tested with `in` for the same reason, so an
explicitly-undefined key still comes off.

Behaviour through the operations API is unchanged, so the existing probes cover it
(a malformed `__unset__` is still rejected at validation, and the directive is
still never stored). The internal-caller path this hardens cannot be reached from
an integration test, so nothing here exercises the new branch directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

dawsontoth and others added 2 commits August 26, 2026 12:34
…n't change

The refactor to `resolveTargetDatabase` made `transformReq` assign `req.schema`
unconditionally. That is not equivalent to what it replaced: the old code left
`req.schema` alone when it was already the resolved value (schema set, no
database), and re-assigning an identical value still throws a TypeError on a
frozen or sealed object under strict mode — which every module here is. Verified:

  old / guarded                 ok
  unconditional (as pushed)     THROWS: TypeError

on a frozen `{ schema: 'data' }`.

`transformReq` is called from ~20 sites across the operations, job, and
replication paths, so an unconditional write widened the blast radius of the
authorization fix for no benefit. Now guarded, which is byte-identical to the old
behaviour on all 11 input shapes (both keys absent, either alone, both, falsy
`0`/`''`/`null` in each position) while keeping the shared resolver.

Nothing is known to pass a frozen request, and this is not confirmed as the cause
of the shard-4 `Worker (index 0) exited with code 0 before reporting ready`
failure on this PR — that suite (`record-caching-cross-worker.test.ts`) passes
locally on this branch, 3/3, and the CI failure was an instance that never booted
rather than an assertion. But a write that cannot change the outcome is not worth
having a failure mode at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Numeric `table: 0` bypassed table authorization and the new backstop.**
`hdbTable` is `Joi.alternatives(Joi.string(), Joi.number())`
(validation/common_validators.ts), so a table named `0` validates — and both
`if (operationSchema && table)` and the backstop's own `if (table && …)` tested it
for truthiness, so it never entered `schemaTableMap` and `hasPermissions` iterated
nothing. That is the same vacuous-truth shape this branch fixes for the target
database, repeated one guard below it, and the same falsy-vs-nullish distinction.
Both now test `!= undefined`, as does `getAttributePermissions`, which skipped
attribute permissions for the same input.

**The bulk-load path could unset an attribute with no permission check.**
`bulkLoad.validateChunk` feeds `insert.validation()`'s attribute list to
`verifyBulkLoadAttributePerms` (dataLayer/bulkLoad.ts:434-444). This branch made
that list skip `__unset__` without contributing the names it removes — the
compensating step `getRecordAttributes` performs for the direct path — so
`import_from_s3` with `action: "update"` and `[{"id":1,"__unset__":["salary"]}]`
deleted `salary` unchecked while the direct `update` returned 403. The async
collector now contributes the removed names; the sync twin in
`insertUpdateValidate.js` deliberately still does not, because that list creates
table attributes and registering one in order to delete it would be backwards.
The two lists are not interchangeable and both comments now say so.

**`__unset__` on an `insert` deleted attributes the same request supplied.**
`createRecords` sets `requires_no_existing`, so `toWrite === record` and the loop
stripped the named keys from the submitted record — a silent partial write
returning 200, while the comments, the authorization scoping and the PR text all
described the directive as inert there. Now refused: an insert has no existing
record to remove from, so the directive can only be a misunderstanding, and a
directive that quietly does nothing is worse than one that says no — the same rule
the primary-key refusal follows.

Also from the review: the default write path no longer allocates a throwaway array
per record (a shared frozen empty array), since the directive is absent from
essentially every record.

Testing: two new probes cover the numeric table and the insert refusal, and both
are mutation-checked — reverting the guards to truthy fails the numeric-table
probe, and applying the directive on insert instead of refusing fails two. 30/30
in the suite; 125 tests across security, terminology, auto-fields and
patch-disjoint-field-merge still pass.

The bulk-load fix has NO test: the JSON bulk path needs `import_from_s3` or
`csv_url_load`, neither reachable from a local integration run, and CSV cannot
express the array the directive takes. It is a read-verified change, and
`validation()`'s attribute list is consumed only by that permission check
(verified — bulkLoad.ts:442 is its sole consumer), so the change is scoped to it.

Two review findings are NOT addressed here: `__unset__` still degrades a merge to
a last-writer-wins put under contention, and the reserved key still has no
migration for tables carrying a pre-existing `__unset__` attribute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Blocker 1 (the merge → last-writer-wins put) is filed as #2350 (P2, Bug) rather than fixed here. The PR body carries the corrected claim, so the __unset__ contract as documented on this branch is single-writer.

#2350 lays out the three options: a removal operation in the resources/crdt.ts registry (which keeps the feature's purpose but needs a reverse that carries the prior value, plus a defined resolution for removal-racing-set), narrowing the documented contract to last-writer-wins like full_record, or restricting the directive to single-node. Option 2 is the honest short-term position if this merges before the design is settled.

🤖 Filed by Claude Code

Comment thread dataLayer/harperBridge/ResourceBridge.ts Outdated
Comment thread validation/insertValidator.ts Outdated
…move from

Delta review of the previous three commits found the insert refusal guarding the
wrong condition. It keyed on `requires_no_existing` — the insert flag — so the
same silent partial write was still reachable one branch over:
`insertUpdateValidate` requires a primary key only for `update`, so
`{operation:'upsert', records:[{name:'Penny', color:'black', __unset__:['color']}]}`
took the `id == undefined → Table.create` path and stored an auto-keyed record with
`color` stripped, returning 200. An `upsert` naming a primary key that isn't stored
had the same shape.

Now keyed on `!existingRecord`, which is the actual invariant: no stored record
means nothing to remove from, so the directive can only be a misunderstanding. That
covers insert, upsert-without-a-key, and upsert-of-an-absent-key together. `update`
never reaches it — a missing record is already skipped upstream.

This is the fix-one-of-N-surfaces mistake: the condition was written from the
operation that exhibited the bug rather than from the property being protected.

Also from the review:

- Managed timestamps are refused off `Table.createdTimeProperty`/
  `updatedTimeProperty` instead of the validator's static legacy list, so a
  schema-declared `createdAt: Float @createdTime` is caught too. Those properties
  resolve `assignCreatedTime` OR the legacy `__createdtime__` spelling
  (resources/Table.ts:504-505), so one check covers both and there is no second
  list to drift. The validator's list is removed rather than kept alongside it.
- The malformed-directive warning is latched and names the table, following
  `warnedNullSourcePut` in resources/Table.ts — it sits on the write and
  replication apply path, where one line per record buries the log under a single
  bad batch.
- Comment density trimmed. Both reviews flagged it; the removed lines narrated
  review history or addressed the reviewer rather than the next reader. Kept: why
  the malformed case warns instead of throwing, why the refusals live in this layer,
  and the falsy-vs-nullish reasoning.

Tests: 33 in the suite, all passing, and the new guards are mutation-checked —
reverting to the insert flag fails the two new upsert probes, and removing the
managed-timestamp loop fails both timestamp probes. 118 more across security,
terminology and auto-fields still pass.

One test correction worth noting: the first version of the upsert probe asserted
"no record with this name and null breed/color", which matched eight records other
probes had legitimately stripped attributes from. It now uses a name unique to the
probe.

Still open from the review, unfixed: `__unset__` remains a whole-record put under
contention (#2350), the directive refusals happen after dynamic attribute
expansion so a rejected write can still have registered new attributes on an open
table, and the bulk-load authorization fix has no test because the JSON bulk path
needs S3/URL input. The CSV entry point takes a different attribute list
(bulkLoad.ts:82, papaparse header fields) which this change does not touch, and
CSV cannot express the array anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread dataLayer/harperBridge/ResourceBridge.ts Outdated
…ot per process

Raised by claude[bot] on #2347, and correct on both counts. The latch was a single
module-level boolean, so the first table to log the warning suppressed it for every
other table on the worker until restart — and the comment cited
`warnedNullSourcePut` as precedent while not matching it. That flag is declared
inside `makeTable` (resources/Table.ts:486) and its own comment says "one warn per
table per worker", so the precedent was per table all along; only this copy was
process-wide.

Now a `WeakSet` keyed on the table, so a bad batch on one table cannot silence the
warning elsewhere, and a dropped table is not kept alive by the latch.

No test: the malformed branch is unreachable through the operations API — the Joi
validator rejects a malformed `__unset__` before the bridge sees it — so this
guards an internal caller that does not exist yet, and asserting on log output
across two tables would not be a meaningful probe of it. Suite still 33/33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think this makes sense. However, would it make sense to add a put operation (upsert + full_record) instead? If we provided this, do we believe that update+full_record functionality and unset are actually meaningful gaps in functionality? Or do you have immediate use for these in the studio?
🤖 Reviewed with Codex

Comment thread utility/operation_authorization.ts Outdated
Comment thread dataLayer/harperBridge/ResourceBridge.ts Outdated
Comment thread dataLayer/harperBridge/ResourceBridge.ts Outdated
Comment thread validation/insertValidator.ts Outdated
Comment thread dataLayer/harperBridge/ResourceBridge.ts Outdated
Comment thread dataLayer/harperBridge/ResourceBridge.ts Outdated
dawsontoth and others added 2 commits August 26, 2026 14:06
…e permissions

Raised by kriszyp on #2347. `getRecordAttributes` returned an empty set for any
request carrying `action`, on the grounds that bulk loads check attribute
permissions per chunk in `bulkLoad.validateChunk`. But `action` is an
unknown-but-accepted key on the insert/update/upsert validator, so adding
`action: "update"` to a DIRECT request skipped every attribute check — the
pre-existing half, plus the `__unset__` removal authorization this branch added.

The opt-out is now keyed on the operation being a known bulk load (`BULK_OPS`,
whose values are the same handler names `requiredPermissions` is keyed by, so it
matches exactly the identity `verifyPerms` already receives), never on a field the
caller controls. Real bulk loads still opt out; a direct request cannot.

Regression test covers all three shapes: the `__unset__` removal denied, the same
request with `action` attached still denied, and a plain write of the same
attribute with `action` attached denied — that last one is the pre-existing half.
Mutation-checked: restoring the `action` test fails it.

Also from the same review: `takeUnsetAttributes` uses an own-property check rather
than `in`. An inherited `__unset__` — a record built on a prototype carrying one,
or after prototype pollution — entered the path with no directive of its own, and
`delete` cannot remove an inherited value, so the caller would go on to delete the
attributes it named. No test: `JSON.parse` yields plain objects, so this is
unreachable through the operations API and guards the same internal boundary the
malformed-value handling does.

Two of that review's findings were already fixed in 14f9dca and need no change
here: managed timestamps are validated off `Table.createdTimeProperty`/
`updatedTimeProperty` with the schema aliases tested, and the no-existing-record
refusal is keyed on `!existingRecord` with upsert-create coverage.

Tests: 34 in the suite; 118 across security/terminology/auto-fields; 581 in
northwind + job-queue, which exercise the CSV and bulk-load paths this change
alters. All pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…operation

Scope reduction, per kriszyp's review on #2347: one operation instead of a flag
plus a per-record directive.

    { "operation": "put", "database": "dev", "table": "dog",
      "records": [{ "id": 1, "dog_name": "Penny" }] }

The stored record becomes exactly that, so an attribute the request omits is
removed. Creates a missing record and replaces an existing one — the same
`Table.put` REST's `PUT /Table/id` performs, so the same audit type, replication
shape, and retained `__createdtime__`. `update`/`upsert` keep merging, which is the
v4-compatible behaviour existing clients depend on and the reason this is a
separate operation rather than semantics changed in place.

This covers what studio#1643 actually needs: its row editor holds the whole record
already. Removed with the flag and the directive:

- `full_record` as a client-facing field on update/upsert
- `__unset__`, its reserved key, its validation, the attribute-collector
  divergence in both `insertUpdateValidate.js` and `insert.ts`, and the
  `getRecordAttributes` name expansion
- `takeUnsetAttributes` and its latch

Three problems retire with them, all raised in review and none now reachable:
`__unset__` degrading a merge to a whole-record put under contention (kept as a
feature request, see below), the reserved key reinterpreting a pre-existing
`__unset__` attribute with no migration, and the directive's interaction with
dynamic attribute expansion.

The internal marker is an ARGUMENT, not a field: `putRecords` calls
`upsertRecords(obj, true)`. Reading it off the request — which the first draft of
this commit did — let a client send `full_record: true` with an `update` and get a
replace, which makes the operation name stop describing the write and bypasses the
attribute-scoped denial, since that is keyed on the operation. A test pins it;
restoring the request read fails that test and nothing else.

Kept from the previous rounds, unchanged in substance:

- The attribute-scoped denial, now keyed on `op === write.put.name`. A replace
  removes what the request omits, and `checkAttributePerms` only sees what it
  supplies.
- The target-database resolution fix and its fail-closed backstop, including the
  numeric `table: 0` guard.
- The bulk-load opt-out keyed on the operation rather than a caller-supplied
  `action`. The `__unset__` half of that fix goes away with the directive; the
  pre-existing half — a direct request skipping every attribute check by attaching
  `action` — stays fixed and stays tested.

Wired as a first-class operation: `OPERATIONS_ENUM.PUT`, the dispatch map, the
replication catchup switch, `requiredPermissions` with `[insert, update]` (what
`upsert` and REST `PUT` both require), the grantable `data` operation group, and
the `new_attributes`/`txn_time` result cleanup. Returns `put_hashes`; a put never
skips.

Tests: `integrationTests/database/put-operation.test.ts`, renamed from
full-record-write — 18 probes, all passing. Every guard mutation-checked: making
put ignore the marker fails 5, reading the marker off the request fails 1, dropping
the attribute-scoped denial fails 1. 143 tests across the security, terminology,
auto-fields and patch-merge suites pass, plus 575 in northwind, which exercises the
CSV and bulk-load paths.

`__unset__` is not abandoned — #2350 is being reworked from "this
is broken" into "add it properly", with concurrency-safe removal at the patch
layer rather than a read-then-replace in the bridge.

Refs: HarperFast/studio#1643

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth dawsontoth changed the title Remove attributes over the operations API (full_record, __unset__), and fix the target-database authorization mismatch feat(operations): add a put operation, and fix the target-database authorization mismatch Aug 27, 2026
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Yes — done in 1ac8d30. put is now the whole feature, and full_record and __unset__ are gone.

On your second question, the honest answer is that neither was a gap:

  • Studio's need is exactly put. fix(queue): waitForDrain poll fallback for drains that never emit #1643 is the row editor: the user edits one record's JSON and saves, so the editor already holds the whole record. Nothing there wants a partial removal.
  • update + full_record was only a safety nicety — "replace, but fail if the record is absent", so a mistyped primary key can't create a stray row. Studio can check that itself, having just loaded the record. Not worth a second spelling of the same write.
  • __unset__ had a real justification — not resending the record — but it wasn't delivering it. Your stale-snapshot analysis is what settled that: resolved in the bridge it's a read-then-replace wearing a patch's clothing, and the audit carries only the resolved put, so the deletion intent can't survive replication. It needs to be a patch-layer operation or it isn't the thing it claims to be.

So it's filed as #2350 for doing properly (now a Feature, P3), with the reverse-value, removal-versus-set resolution, and namespace problems written up. Dawson wants it eventually — it's a genuinely nicer tool than put for "drop one field, leave the rest alone" — just not as a read-then-replace.

Three of your findings retire with the directive rather than being fixed: the stale snapshot, the reserved-key compatibility hazard (no key to reserve now), and the refusal-after-attribute-expansion ordering. The two that were about put's own surface are fixed and tested.

One thing your review caused that I'd flag: collapsing to a single operation exposed a bug in my own first draft of it. I initially had putRecords set full_record on the request object, which meant a client could send full_record: true with an update and get a replace — defeating the point of keying semantics on the operation name, and bypassing the attribute-scoped denial, which is keyed on the operation. It's an argument now, and there's a test that fails if it goes back on the request.

🤖 Reply from Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants